Unverified Commit 5baf5fc9 authored by cokemine's avatar cokemine
Browse files

1.0.0-alpha

parents
Showing with 880 additions and 0 deletions
+880 -0
module.exports = {
root: true,
env: {
browser: true,
commonjs: true,
es6: true,
node: true,
},
extends: [ 'eslint:recommended', 'plugin:@typescript-eslint/recommended' ],
parser: '@typescript-eslint/parser',
plugins: [ '@typescript-eslint' ],
parserOptions: {
ecmaFeatures: {
jsx: true
},
ecmaVersion: 2021,
sourceType: 'module'
},
rules: {
indent: [ 1, 2 ],
semi: [ 1, 'always' ],
quotes: [ 1, 'single' ],
'require-await': 2,
'no-return-await': 2,
'@typescript-eslint/no-var-requires': 0,
'@typescript-eslint/no-explicit-any': 0
}
};
.gitignore 0 → 100644
.DS_Store
node_modules
/dist
# local env files
.env.local
.env.*.local
# Log files
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# Editor directories and files
.idea
.vscode
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
db.sqlite
[submodule "web/hotaru-theme"]
path = web/hotaru-theme
url = https://github.com/cokemine/Hotaru_theme
branch = nodestatus
LICENSE 0 → 100644
MIT License
Copyright (c) 2021 神楽坂みずき
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
#!/usr/bin/env node
const os = require('os');
const net = require('net');
const EventEmitter = require('events');
const inquirer = require('inquirer');
const chalk = require('chalk');
const figlet = require('figlet');
const { program } = require('commander');
const countries = require('i18n-iso-countries');
const ERROR = chalk.red('[ERROR]');
const INFO = chalk.green('[INFO]');
const emitter = new EventEmitter();
const socket = net.connect({
path: os.platform() === 'win32' ? '\\\\.\\pipe\\nodestatus_ipc' : '/tmp/nodestatus_unix.sock'
});
socket.on('error', error => {
console.log(ERROR + ` 启动 NodeStatus-cli 出错, 错误信息: ${error.message}, 请检查 NodeStatus 是否正常运行`);
});
socket.on('data', buf => {
const status = JSON.parse(buf.toString());
if (status.data) {
emitter.emit('list', status.data);
return;
}
if (status.code) {
console.log(ERROR + ` 请求失败, 错误信息: ${status.msg}`);
} else {
console.log(INFO + ` 请求成功: ${status.msg}`);
}
process.exit(status.code);
});
function getCode(value) {
const code = countries.getAlpha2Code(value, 'zh');
const codeEn = countries.getAlpha2Code(value, 'en');
return code || codeEn || value.toUpperCase();
}
const questions = [
{
name: 'username',
type: 'input',
message: '请输入 NodeStatus 客户端的用户名[username]:'
},
{
name: 'password',
type: 'input',
message: '请输入 NodeStatus 服务端要设置的密码[password]:'
},
{
name: 'name',
type: 'input',
message: '请输入 NodeStatus 服务端要设置的节点名称[name]:'
},
{
name: 'type',
type: 'input',
message: '请输入 NodeStatus 服务端要设置的节点虚拟化类型[type] (例如 OpenVZ / KVM) :'
},
{
name: 'location',
type: 'input',
message: '请输入 NodeStatus 服务端要设置的节点位置[location]:',
},
{
name: 'region',
type: 'input',
message: '请输入 NodeStatus 服务端要设置的节点地区[region]:',
validate(value) {
const code = getCode(value);
if (countries.isValid(code)) return true;
else return '你输入的节点地区不合法';
},
transformer: getCode
},
{
name: 'disabled',
type: 'list',
message: '请输入 NodeStatus 服务端要设置的节点状态[disabled]:',
choices: [ 'true', 'false' ],
when: () => false
}
];
function createQuestions(key) {
return questions.map(item => {
if (key === 'disabled' && item.name === 'disabled')
return {
...item,
when: () => true
};
if (item.name !== key)
return {
...item,
when: () => false
};
return item;
});
}
/* Add Method */
async function handleAdd() {
const answer = await inquirer.prompt(questions);
answer.region = getCode(answer.region);
socket.write(`add @;@ ${JSON.stringify(answer)}`);
}
/* List Method */
function handleList() {
socket.write('list');
emitter.once('list', msg => {
console.table(msg);
process.exit(0);
});
}
/* Modify Method */
function handleModify() {
socket.write('list');
emitter.once('list', async data => {
console.table(data);
const { username, key } = await inquirer.prompt([ {
name: 'username',
type: 'list',
choices: data.map(item => item.username),
message: '请输入要修改的节点用户名:'
}, {
name: 'key',
type: 'list',
message: '请输入需要修改的客户端属性:',
choices: [ 'username', 'password', 'name', 'type', 'location', 'region', 'disabled' ]
} ]);
let answer = await inquirer.prompt(createQuestions(key), { username });
if (answer.region) answer.region = getCode(answer.region);
if (key === 'username') {
const { newUserName } = await inquirer.prompt([
{
name: 'newUserName',
type: 'input',
message: '请输入新节点的用户名[newUserName]:'
}
]);
answer = Object.assign(answer, { newUserName });
}
socket.write(`set @;@ ${JSON.stringify(answer)}`);
});
}
/* Delete Method */
function handleDelete() {
socket.write('list');
emitter.on('list', async data => {
console.table(data);
const answer = await inquirer.prompt([
{
name: 'username',
type: 'list',
message: '请输入要删除的节点用户名',
choices: data.map(item => item.username)
}
]);
socket.write(`del @;@ ${answer.username}`);
});
}
function init() {
console.log(
chalk.green(
figlet.textSync('Node Status', {
font: 'Ghost',
horizontalLayout: 'default',
verticalLayout: 'default',
})
)
);
}
(() => {
init();
program
.command('add')
.description('Add a new Server')
.action(handleAdd);
program
.command('list')
.description('List all servers')
.action(handleList);
program
.command('set')
.description('Modify a server\' s configuration')
.action(handleModify);
program
.command('del')
.description('Delete a server')
.action(handleDelete);
program.parse(process.argv);
})();
#!/usr/bin/env node
require('../dist/app');
{
"name": "nodestatus",
"version": "1.0.0-alpha",
"main": "index.js",
"license": "MIT",
"author": "Kagurazaka Mizuki",
"bin": {
"status-cli": "./bin/status-cli.js",
"status-server": "./bin/status-server.js"
},
"scripts": {
"postinstall": "node script/postinstall.js",
"postbuild": "node script/postbuild.js",
"dev": "cross-env NODE_ENV=development nodemon server/app.ts --watch ./server",
"lint": "eslint . --ext .js --ext .ts --ignore-pattern dist --fix",
"build": "tsc",
"start": "cross-env NODE_ENV=production node dist/app.js"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.2",
"@types/koa": "^2.13.4",
"@types/koa-mount": "^4.0.0",
"@types/koa-static": "^4.0.2",
"@types/koa-webpack": "^6.0.4",
"@types/log4js": "^2.3.5",
"@types/msgpack-lite": "^0.1.8",
"@types/node": "^16.6.1",
"@types/ws": "^7.4.7",
"@typescript-eslint/eslint-plugin": "^4.29.2",
"@typescript-eslint/parser": "^4.29.2",
"eslint": "^7.32.0",
"koa-webpack": "^6.0.0",
"nodemon": "^2.0.12",
"ts-node": "^10.2.0",
"typescript": "^4.3.5",
"webpack": "4"
},
"dependencies": {
"bcryptjs": "^2.4.3",
"chalk": "^4.1.2",
"commander": "^8.1.0",
"cross-env": "^7.0.3",
"figlet": "^1.5.2",
"i18n-iso-countries": "^6.8.0",
"inquirer": "^8.1.2",
"koa": "^2.13.1",
"koa-mount": "^4.0.0",
"koa-static": "^5.0.0",
"log4js": "^6.3.0",
"msgpack-lite": "^0.1.26",
"reflect-metadata": "^0.1.13",
"sequelize": "^6.6.5",
"sequelize-typescript": "^2.1.0",
"sqlite3": "^5.0.2",
"ws": "^8.1.0"
}
}
const cp = require('child_process');
const { findModPath } = require('./utils');
const list = findModPath();
list.forEach(({ modPath, cmd }) => {
cp.spawn(cmd, [ 'run', 'build' ], {
env: process.env,
cwd: modPath,
stdio: 'inherit'
});
});
const cp = require('child_process');
const { findModPath } = require('./utils');
const list = findModPath();
list.forEach(({ modPath, cmd }) => {
cp.spawn(cmd, [ cmd.startsWith('npm') ? 'ci' : ' --frozen-lockfile' ], {
env: process.env,
cwd: modPath,
stdio: 'inherit'
});
});
const fs = require('fs');
const os = require('os');
const resolve = require('path').resolve;
const libDir = resolve(__dirname, '../web');
function findModPath() {
const lib = fs.readdirSync(libDir);
const list = [];
for (const mod of lib) {
const modPath = resolve(libDir, mod);
const exist = fileName => {
return fs.existsSync(resolve(modPath, fileName));
};
if (!exist('package.json')) {
continue;
}
let cmd = exist('yarn.lock') ? 'yarn' : 'npm';
os.platform().startsWith('win') && (cmd += '.cmd');
list.push({ modPath, cmd });
}
return list;
}
module.exports = {
findModPath
};
import { resolve } from 'path';
import { program } from 'commander';
program
.option('-db, --database <db>', 'the path of database', resolve(__dirname, '../db.sqlite'))
.option('-p, --port <port>', 'the port of NodeStatus', '35601')
.option('-i, --interval <interval>', 'update interval', '1500')
.parse(process.argv);
const options = program.opts();
process.env.db = options.database;
process.env.port = options.port;
process.env.interval = options.interval;
import db from './lib/db';
db.sync({ alter: true }).then(() =>
process.env.NODE_ENV === 'development'
? require('./lib/app.dev')
: require('./lib/app.prod')
);
import {
getServerPassword,
getListServers as _getListServers,
addServer as _addServer,
getServer,
setServer as _setServer,
delServer as _delServer
} from '../model/server';
import { compareSync } from 'bcryptjs';
import type { IServer, IResp, Box } from '../../types/server';
import { createRes } from '../lib/utils';
const validKeys = ['username', 'password', 'name', 'type', 'location', 'region', 'disabled'];
export async function authServer(username: string, password: string): Promise<boolean> {
const result = await getServerPassword(username);
if (result.code) return false;
return compareSync(password, result.msg);
}
export async function addServer(obj: IServer): Promise<IResp> {
validKeys.forEach(str => {
if (!Object.prototype.hasOwnProperty.call(obj, str)) {
return createRes(1, 'Check the details');
}
});
if (Object.keys(obj).length !== 6) {
return createRes(1, 'Check the details');
}
const result = await getServer(obj.username);
if (!result.code) {
return createRes(1, 'Username duplicate');
}
return _addServer(obj);
}
export async function setServer(username: string, obj: IServer): Promise<IResp> {
const result = await getServer(username);
if (result.code) return result;
for (const str of Object.keys(obj)) {
if (!validKeys.includes(str)) {
return createRes(1, 'Check the details');
}
}
return _setServer(username, obj);
}
export async function delServer(username: string): Promise<IResp> {
const result = await getServer(username);
if (result.code) {
return result;
}
return _delServer(username);
}
export async function getListServers(): Promise<IResp> {
const result = await _getListServers();
if (result.code) return result;
const obj: Box = {};
(result.data as IServer[]).forEach(item => {
if (item.disabled) return;
const { username, ..._item } = item;
obj[username] = _item;
});
return createRes({ data: obj });
}
export {
_getListServers as getRawListServers
};
import Koa from 'koa';
import koaWebpack from 'koa-webpack';
import mount from 'koa-mount';
import { resolve } from 'path';
import { Server } from 'http';
import { createIO } from './io';
import { logger } from './utils';
const getVueWebpackConfig = (name: string) => {
process.env.VUE_CLI_CONTEXT = resolve(__dirname, `../../web/${ name }`);
return require(`../../web/${ name }/node_modules/@vue/cli-service/webpack.config`);
};
(async () => {
const app = new Koa();
const server = new Server(app.callback());
app.use(mount('/', await koaWebpack({
config: getVueWebpackConfig('hotaru-theme'),
devMiddleware: {
publicPath: '/'
}
})));
await createIO(server);
server.listen(process.env.port, () => logger.info(`🎉 NodeStatus is listening on http://localhost:${ process.env.port }`));
})();
import Koa from 'koa';
import serve from 'koa-static';
import { resolve } from 'path';
import { logger } from './utils';
import { Server } from 'http';
import { createIO } from './io';
(async () => {
const app = new Koa();
app.use(serve(resolve('./web/hotaru-theme/dist'), {
maxage: 2592000
}));
const server = new Server(app.callback());
await createIO(server);
server.listen(process.env.port, () => logger.info(`🎉 NodeStatus is listening on http://localhost:${ process.env.port }`));
})();
import { Sequelize } from 'sequelize-typescript';
import { resolve } from 'path';
const sequelize = new Sequelize({
dialect: 'sqlite',
storage: process.env.db,
logging: false,
models: [resolve(__dirname, '../schema')]
});
export default sequelize;
import os from 'os';
import ws from 'ws';
import { Server } from 'http';
import { isIPv4 } from 'net';
import msgpack from 'msgpack-lite';
import { getListServers, authServer } from '../controller/server';
import ipc from './ipc';
import { logger } from './utils';
import type { Box, ServerItem, Servers } from '../../types/server';
class NodeStatus {
private ioPub = new ws.Server({ noServer: true });
private ioConn = new ws.Server({ noServer: true });
private servers: Servers = {};
private serversPub: ServerItem[] = [];
private map = new WeakMap<ws, string>();
private isBanned = new Map<string, boolean>();
private setBan(address: string, t: number, reason: string): void {
if (this.isBanned.get(address)) return;
this.isBanned.set(address, true);
logger.warn(`${ address } was banned ${ t } seconds, reason: ${ reason }`);
const id = setTimeout(() => {
this.isBanned.delete(address);
clearTimeout(id);
}, t * 1000);
}
public init(server: Server) {
server.on('upgrade', (request, socket, head) => {
const pathname = request.url;
if (pathname === '/connect') {
this.ioConn.handleUpgrade(request, socket as any, head, ws => {
this.map.set(ws, (request.headers?.['x-forwarded-for'] as any)?.split(',')?.[0]?.trim() || request.socket.remoteAddress);
this.ioConn.emit('connection', ws);
});
} else if (pathname === '/public') {
this.ioPub.handleUpgrade(request, socket as any, head, ws => {
this.ioPub.emit('connection', ws);
});
} else {
socket.destroy();
}
});
this.ioConn.on('connection', socket => {
const address = this.map.get(socket);
if (typeof address === 'undefined') {
return socket.close();
}
socket.send('Authentication required');
logger.info(`${ address } is trying to connect to server`);
socket.once('message', async (buf: Buffer) => {
let username = '', password = '';
if (this.isBanned.get(address)) {
socket.send('You are banned. Please try to connect after 60 / 120 seconds');
} else try {
({ username, password } = msgpack.decode(buf));
username = username.trim();
password = password.trim();
if (Object.keys(this.servers[username].status).length) {
socket.send('Only one connection per user allowed.');
this.setBan(address, 120, 'Only one connection per user allowed.');
}
} catch (e) {
socket.send('Please check your login details.');
this.setBan(address, 120, 'it is an idiot.');
socket.close();
}
if (!username || !password) {
socket.send('Username or password must not be blank.');
this.setBan(address, 60, 'username or password was blank');
} else if (!await authServer(username, password)) {
socket.send('Wrong username and/or password.');
this.setBan(address, 60, 'use wrong username and/or password.');
} else {
socket.send('Authentication successful. Access granted.');
socket.send(`You are connecting via: ${ isIPv4(address) ? 'IPv4' : 'IPv6' }`);
logger.info(`${ address } has connected to server`);
socket.on('message', (buf: Buffer) => this.servers[username]['status'] = msgpack.decode(buf));
socket.once('close', () => {
this.servers[username]['status'] = {};
logger.warn(`${ address } disconnected`);
});
}
});
});
this.ioPub.on('connection', socket => {
const runPush = () =>
socket.send(JSON.stringify({
servers: this.serversPub,
updated: ~~(Date.now() / 1000)
}));
runPush();
const id = setInterval(runPush, Number(process.env.interval));
socket.on('close', () => clearInterval(id));
});
}
public async updateStatus(username ?: string) {
const box = (await getListServers()).data as Box;
if (username) {
if (!box[username])
delete this.servers[username];
else this.servers[username] = Object.assign(box[username], { status: this.servers?.[username]?.status || {} });
} else {
for (const k of Object.keys(box)) {
if (!this.servers[k]) this.servers[k] = Object.assign(box[k], { status: {} });
}
}
this.serversPub = Object.values(this.servers).sort((x, y) => y.id - x.id);
}
}
export const instance = new NodeStatus();
export async function createIO(server: Server): Promise<void> {
await instance.updateStatus();
instance.init(server);
ipc.listen(os.platform() === 'win32' ? '\\\\.\\pipe\\nodestatus_ipc' : '/tmp/nodestatus_unix.sock');
}
import net from 'net';
import { instance } from './io';
import { addServer, delServer, setServer, getRawListServers } from '../controller/server';
import { createRes } from './utils';
import type { IServer } from '../../types/server';
export default net.createServer(client => {
client.on('data', async (buf: Buffer) => {
try {
const [method, payload] = buf.toString().trim().split(' @;@ ');
switch (method) {
case 'add': {
const data: IServer = JSON.parse(payload);
const status = await addServer(data);
client.write(JSON.stringify(status));
if (!status.code) await instance.updateStatus(data.username);
break;
}
case 'list': {
const status = await getRawListServers();
client.write(JSON.stringify(status));
break;
}
case 'set': {
const obj = JSON.parse(payload);
const { username } = obj;
delete obj.username;
if (obj.newUserName) {
obj.username = obj.newUserName;
delete obj.newUserName;
}
const status = await setServer(username, obj);
client.write(JSON.stringify(status));
if (!status.code) username === obj.username
? await instance.updateStatus(username)
: await Promise.all([instance.updateStatus(username), instance.updateStatus(obj.username)]);
break;
}
case 'del': {
const status = await delServer(payload);
client.write(JSON.stringify(status));
if (!status.code) await instance.updateStatus(payload);
break;
}
}
} catch (error) {
client.write(JSON.stringify(createRes(1, error.message)));
}
});
});
import { configure, getLogger } from 'log4js';
import { IResp } from '../../types/server';
configure({
appenders: {
out: { type: 'stdout' },
},
categories: {
default: { appenders: ['out'], level: 'info' }
}
});
export const logger = getLogger();
export const createRes = (code: 0 | 1 | Partial<IResp> = 0, msg = 'ok', data: Record<string, any> | null = null): IResp => {
if (typeof code === 'object') {
const {
code: _code = 0,
msg = 'ok',
data = null
} = code;
return {
code: _code,
msg,
data
};
}
return {
code,
msg,
data
};
};
import Server from '../schema/server';
import { IServer, IResp } from '../../types/server';
import { createRes } from '../lib/utils';
async function handleRequest(callback: () => Promise<IResp>): Promise<IResp> {
try {
return await callback();
} catch (error) {
return createRes(1, error.message);
}
}
export function getServer(username: string): Promise<IResp> {
return handleRequest(async () => {
const result = await Server.findOne({
where: {
username
}
});
return createRes(
result ? 0 : 1,
result ? 'ok' : 'User Not Found',
result
);
});
}
export function getServerPassword(username: string): Promise<IResp> {
return handleRequest(async () => {
const result = await getServer(username);
if (result.code) return result;
return createRes(0, (result.data as IServer).password);
});
}
export function addServer(server: IServer): Promise<IResp> {
return handleRequest(async () => {
await Server.create(server);
return createRes();
});
}
export function delServer(username: string): Promise<IResp> {
return handleRequest(async () => {
await Server.destroy({
where: {
username
}
});
return createRes();
});
}
export function setServer(username: string, obj: IServer): Promise<IResp> {
return handleRequest(async () => {
await Server.update(obj, {
where: {
username
}
});
return createRes();
});
}
export function getListServers(): Promise<IResp> {
return handleRequest(async () => {
const result = await Server.findAll({
attributes: {
exclude: ['password', 'createdAt', 'updatedAt', 'disabled']
},
raw: true
});
return createRes({ data: result });
});
}
import { Table, Column, Model, Unique, Default } from 'sequelize-typescript';
import { hashSync } from 'bcryptjs';
@Table({
underscored: true
})
export default class Server extends Model {
@Unique
@Column
username!: string
@Column
get password(): string {
return this.getDataValue('password');
}
set password(password: string) {
this.setDataValue('password', hashSync(password));
}
@Column
name!: string;
@Column
type!: string;
@Column
location!: string;
@Column
region!: string;
@Default(false)
@Column
disabled!: boolean;
}
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment