Last active
September 3, 2026 11:48
-
-
Save anhtuank7c/ef7ac27df205d70cf1f789bb420ec013 to your computer and use it in GitHub Desktop.
napp - tiện ích quản lý ứng dụng web nodejs trên Ubuntu
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env node | |
| // __NAPP_MARKER__ version=1.26.0 | |
| "use strict"; | |
| var __create = Object.create; | |
| var __defProp = Object.defineProperty; | |
| var __getOwnPropDesc = Object.getOwnPropertyDescriptor; | |
| var __getOwnPropNames = Object.getOwnPropertyNames; | |
| var __getProtoOf = Object.getPrototypeOf; | |
| var __hasOwnProp = Object.prototype.hasOwnProperty; | |
| var __commonJS = (cb, mod) => function __require() { | |
| return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports; | |
| }; | |
| var __copyProps = (to, from, except, desc) => { | |
| if (from && typeof from === "object" || typeof from === "function") { | |
| for (let key of __getOwnPropNames(from)) | |
| if (!__hasOwnProp.call(to, key) && key !== except) | |
| __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); | |
| } | |
| return to; | |
| }; | |
| var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps( | |
| // If the importer is in node compatibility mode or this is not an ESM | |
| // file that has been converted to a CommonJS file using a Babel- | |
| // compatible transform (i.e. "__esModule" has not been set), then set | |
| // "default" to the CommonJS "module.exports" for node compatibility. | |
| isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target, | |
| mod | |
| )); | |
| // node_modules/.pnpm/commander@12.1.0/node_modules/commander/lib/error.js | |
| var require_error = __commonJS({ | |
| "node_modules/.pnpm/commander@12.1.0/node_modules/commander/lib/error.js"(exports2) { | |
| var CommanderError2 = class extends Error { | |
| /** | |
| * Constructs the CommanderError class | |
| * @param {number} exitCode suggested exit code which could be used with process.exit | |
| * @param {string} code an id string representing the error | |
| * @param {string} message human-readable description of the error | |
| */ | |
| constructor(exitCode, code, message) { | |
| super(message); | |
| Error.captureStackTrace(this, this.constructor); | |
| this.name = this.constructor.name; | |
| this.code = code; | |
| this.exitCode = exitCode; | |
| this.nestedError = void 0; | |
| } | |
| }; | |
| var InvalidArgumentError2 = class extends CommanderError2 { | |
| /** | |
| * Constructs the InvalidArgumentError class | |
| * @param {string} [message] explanation of why argument is invalid | |
| */ | |
| constructor(message) { | |
| super(1, "commander.invalidArgument", message); | |
| Error.captureStackTrace(this, this.constructor); | |
| this.name = this.constructor.name; | |
| } | |
| }; | |
| exports2.CommanderError = CommanderError2; | |
| exports2.InvalidArgumentError = InvalidArgumentError2; | |
| } | |
| }); | |
| // node_modules/.pnpm/commander@12.1.0/node_modules/commander/lib/argument.js | |
| var require_argument = __commonJS({ | |
| "node_modules/.pnpm/commander@12.1.0/node_modules/commander/lib/argument.js"(exports2) { | |
| var { InvalidArgumentError: InvalidArgumentError2 } = require_error(); | |
| var Argument2 = class { | |
| /** | |
| * Initialize a new command argument with the given name and description. | |
| * The default is that the argument is required, and you can explicitly | |
| * indicate this with <> around the name. Put [] around the name for an optional argument. | |
| * | |
| * @param {string} name | |
| * @param {string} [description] | |
| */ | |
| constructor(name, description) { | |
| this.description = description || ""; | |
| this.variadic = false; | |
| this.parseArg = void 0; | |
| this.defaultValue = void 0; | |
| this.defaultValueDescription = void 0; | |
| this.argChoices = void 0; | |
| switch (name[0]) { | |
| case "<": | |
| this.required = true; | |
| this._name = name.slice(1, -1); | |
| break; | |
| case "[": | |
| this.required = false; | |
| this._name = name.slice(1, -1); | |
| break; | |
| default: | |
| this.required = true; | |
| this._name = name; | |
| break; | |
| } | |
| if (this._name.length > 3 && this._name.slice(-3) === "...") { | |
| this.variadic = true; | |
| this._name = this._name.slice(0, -3); | |
| } | |
| } | |
| /** | |
| * Return argument name. | |
| * | |
| * @return {string} | |
| */ | |
| name() { | |
| return this._name; | |
| } | |
| /** | |
| * @package | |
| */ | |
| _concatValue(value, previous) { | |
| if (previous === this.defaultValue || !Array.isArray(previous)) { | |
| return [value]; | |
| } | |
| return previous.concat(value); | |
| } | |
| /** | |
| * Set the default value, and optionally supply the description to be displayed in the help. | |
| * | |
| * @param {*} value | |
| * @param {string} [description] | |
| * @return {Argument} | |
| */ | |
| default(value, description) { | |
| this.defaultValue = value; | |
| this.defaultValueDescription = description; | |
| return this; | |
| } | |
| /** | |
| * Set the custom handler for processing CLI command arguments into argument values. | |
| * | |
| * @param {Function} [fn] | |
| * @return {Argument} | |
| */ | |
| argParser(fn) { | |
| this.parseArg = fn; | |
| return this; | |
| } | |
| /** | |
| * Only allow argument value to be one of choices. | |
| * | |
| * @param {string[]} values | |
| * @return {Argument} | |
| */ | |
| choices(values) { | |
| this.argChoices = values.slice(); | |
| this.parseArg = (arg, previous) => { | |
| if (!this.argChoices.includes(arg)) { | |
| throw new InvalidArgumentError2( | |
| `Allowed choices are ${this.argChoices.join(", ")}.` | |
| ); | |
| } | |
| if (this.variadic) { | |
| return this._concatValue(arg, previous); | |
| } | |
| return arg; | |
| }; | |
| return this; | |
| } | |
| /** | |
| * Make argument required. | |
| * | |
| * @returns {Argument} | |
| */ | |
| argRequired() { | |
| this.required = true; | |
| return this; | |
| } | |
| /** | |
| * Make argument optional. | |
| * | |
| * @returns {Argument} | |
| */ | |
| argOptional() { | |
| this.required = false; | |
| return this; | |
| } | |
| }; | |
| function humanReadableArgName(arg) { | |
| const nameOutput = arg.name() + (arg.variadic === true ? "..." : ""); | |
| return arg.required ? "<" + nameOutput + ">" : "[" + nameOutput + "]"; | |
| } | |
| exports2.Argument = Argument2; | |
| exports2.humanReadableArgName = humanReadableArgName; | |
| } | |
| }); | |
| // node_modules/.pnpm/commander@12.1.0/node_modules/commander/lib/help.js | |
| var require_help = __commonJS({ | |
| "node_modules/.pnpm/commander@12.1.0/node_modules/commander/lib/help.js"(exports2) { | |
| var { humanReadableArgName } = require_argument(); | |
| var Help2 = class { | |
| constructor() { | |
| this.helpWidth = void 0; | |
| this.sortSubcommands = false; | |
| this.sortOptions = false; | |
| this.showGlobalOptions = false; | |
| } | |
| /** | |
| * Get an array of the visible subcommands. Includes a placeholder for the implicit help command, if there is one. | |
| * | |
| * @param {Command} cmd | |
| * @returns {Command[]} | |
| */ | |
| visibleCommands(cmd) { | |
| const visibleCommands = cmd.commands.filter((cmd2) => !cmd2._hidden); | |
| const helpCommand = cmd._getHelpCommand(); | |
| if (helpCommand && !helpCommand._hidden) { | |
| visibleCommands.push(helpCommand); | |
| } | |
| if (this.sortSubcommands) { | |
| visibleCommands.sort((a, b) => { | |
| return a.name().localeCompare(b.name()); | |
| }); | |
| } | |
| return visibleCommands; | |
| } | |
| /** | |
| * Compare options for sort. | |
| * | |
| * @param {Option} a | |
| * @param {Option} b | |
| * @returns {number} | |
| */ | |
| compareOptions(a, b) { | |
| const getSortKey = (option) => { | |
| return option.short ? option.short.replace(/^-/, "") : option.long.replace(/^--/, ""); | |
| }; | |
| return getSortKey(a).localeCompare(getSortKey(b)); | |
| } | |
| /** | |
| * Get an array of the visible options. Includes a placeholder for the implicit help option, if there is one. | |
| * | |
| * @param {Command} cmd | |
| * @returns {Option[]} | |
| */ | |
| visibleOptions(cmd) { | |
| const visibleOptions = cmd.options.filter((option) => !option.hidden); | |
| const helpOption = cmd._getHelpOption(); | |
| if (helpOption && !helpOption.hidden) { | |
| const removeShort = helpOption.short && cmd._findOption(helpOption.short); | |
| const removeLong = helpOption.long && cmd._findOption(helpOption.long); | |
| if (!removeShort && !removeLong) { | |
| visibleOptions.push(helpOption); | |
| } else if (helpOption.long && !removeLong) { | |
| visibleOptions.push( | |
| cmd.createOption(helpOption.long, helpOption.description) | |
| ); | |
| } else if (helpOption.short && !removeShort) { | |
| visibleOptions.push( | |
| cmd.createOption(helpOption.short, helpOption.description) | |
| ); | |
| } | |
| } | |
| if (this.sortOptions) { | |
| visibleOptions.sort(this.compareOptions); | |
| } | |
| return visibleOptions; | |
| } | |
| /** | |
| * Get an array of the visible global options. (Not including help.) | |
| * | |
| * @param {Command} cmd | |
| * @returns {Option[]} | |
| */ | |
| visibleGlobalOptions(cmd) { | |
| if (!this.showGlobalOptions) return []; | |
| const globalOptions = []; | |
| for (let ancestorCmd = cmd.parent; ancestorCmd; ancestorCmd = ancestorCmd.parent) { | |
| const visibleOptions = ancestorCmd.options.filter( | |
| (option) => !option.hidden | |
| ); | |
| globalOptions.push(...visibleOptions); | |
| } | |
| if (this.sortOptions) { | |
| globalOptions.sort(this.compareOptions); | |
| } | |
| return globalOptions; | |
| } | |
| /** | |
| * Get an array of the arguments if any have a description. | |
| * | |
| * @param {Command} cmd | |
| * @returns {Argument[]} | |
| */ | |
| visibleArguments(cmd) { | |
| if (cmd._argsDescription) { | |
| cmd.registeredArguments.forEach((argument) => { | |
| argument.description = argument.description || cmd._argsDescription[argument.name()] || ""; | |
| }); | |
| } | |
| if (cmd.registeredArguments.find((argument) => argument.description)) { | |
| return cmd.registeredArguments; | |
| } | |
| return []; | |
| } | |
| /** | |
| * Get the command term to show in the list of subcommands. | |
| * | |
| * @param {Command} cmd | |
| * @returns {string} | |
| */ | |
| subcommandTerm(cmd) { | |
| const args = cmd.registeredArguments.map((arg) => humanReadableArgName(arg)).join(" "); | |
| return cmd._name + (cmd._aliases[0] ? "|" + cmd._aliases[0] : "") + (cmd.options.length ? " [options]" : "") + // simplistic check for non-help option | |
| (args ? " " + args : ""); | |
| } | |
| /** | |
| * Get the option term to show in the list of options. | |
| * | |
| * @param {Option} option | |
| * @returns {string} | |
| */ | |
| optionTerm(option) { | |
| return option.flags; | |
| } | |
| /** | |
| * Get the argument term to show in the list of arguments. | |
| * | |
| * @param {Argument} argument | |
| * @returns {string} | |
| */ | |
| argumentTerm(argument) { | |
| return argument.name(); | |
| } | |
| /** | |
| * Get the longest command term length. | |
| * | |
| * @param {Command} cmd | |
| * @param {Help} helper | |
| * @returns {number} | |
| */ | |
| longestSubcommandTermLength(cmd, helper) { | |
| return helper.visibleCommands(cmd).reduce((max, command) => { | |
| return Math.max(max, helper.subcommandTerm(command).length); | |
| }, 0); | |
| } | |
| /** | |
| * Get the longest option term length. | |
| * | |
| * @param {Command} cmd | |
| * @param {Help} helper | |
| * @returns {number} | |
| */ | |
| longestOptionTermLength(cmd, helper) { | |
| return helper.visibleOptions(cmd).reduce((max, option) => { | |
| return Math.max(max, helper.optionTerm(option).length); | |
| }, 0); | |
| } | |
| /** | |
| * Get the longest global option term length. | |
| * | |
| * @param {Command} cmd | |
| * @param {Help} helper | |
| * @returns {number} | |
| */ | |
| longestGlobalOptionTermLength(cmd, helper) { | |
| return helper.visibleGlobalOptions(cmd).reduce((max, option) => { | |
| return Math.max(max, helper.optionTerm(option).length); | |
| }, 0); | |
| } | |
| /** | |
| * Get the longest argument term length. | |
| * | |
| * @param {Command} cmd | |
| * @param {Help} helper | |
| * @returns {number} | |
| */ | |
| longestArgumentTermLength(cmd, helper) { | |
| return helper.visibleArguments(cmd).reduce((max, argument) => { | |
| return Math.max(max, helper.argumentTerm(argument).length); | |
| }, 0); | |
| } | |
| /** | |
| * Get the command usage to be displayed at the top of the built-in help. | |
| * | |
| * @param {Command} cmd | |
| * @returns {string} | |
| */ | |
| commandUsage(cmd) { | |
| let cmdName = cmd._name; | |
| if (cmd._aliases[0]) { | |
| cmdName = cmdName + "|" + cmd._aliases[0]; | |
| } | |
| let ancestorCmdNames = ""; | |
| for (let ancestorCmd = cmd.parent; ancestorCmd; ancestorCmd = ancestorCmd.parent) { | |
| ancestorCmdNames = ancestorCmd.name() + " " + ancestorCmdNames; | |
| } | |
| return ancestorCmdNames + cmdName + " " + cmd.usage(); | |
| } | |
| /** | |
| * Get the description for the command. | |
| * | |
| * @param {Command} cmd | |
| * @returns {string} | |
| */ | |
| commandDescription(cmd) { | |
| return cmd.description(); | |
| } | |
| /** | |
| * Get the subcommand summary to show in the list of subcommands. | |
| * (Fallback to description for backwards compatibility.) | |
| * | |
| * @param {Command} cmd | |
| * @returns {string} | |
| */ | |
| subcommandDescription(cmd) { | |
| return cmd.summary() || cmd.description(); | |
| } | |
| /** | |
| * Get the option description to show in the list of options. | |
| * | |
| * @param {Option} option | |
| * @return {string} | |
| */ | |
| optionDescription(option) { | |
| const extraInfo = []; | |
| if (option.argChoices) { | |
| extraInfo.push( | |
| // use stringify to match the display of the default value | |
| `choices: ${option.argChoices.map((choice) => JSON.stringify(choice)).join(", ")}` | |
| ); | |
| } | |
| if (option.defaultValue !== void 0) { | |
| const showDefault = option.required || option.optional || option.isBoolean() && typeof option.defaultValue === "boolean"; | |
| if (showDefault) { | |
| extraInfo.push( | |
| `default: ${option.defaultValueDescription || JSON.stringify(option.defaultValue)}` | |
| ); | |
| } | |
| } | |
| if (option.presetArg !== void 0 && option.optional) { | |
| extraInfo.push(`preset: ${JSON.stringify(option.presetArg)}`); | |
| } | |
| if (option.envVar !== void 0) { | |
| extraInfo.push(`env: ${option.envVar}`); | |
| } | |
| if (extraInfo.length > 0) { | |
| return `${option.description} (${extraInfo.join(", ")})`; | |
| } | |
| return option.description; | |
| } | |
| /** | |
| * Get the argument description to show in the list of arguments. | |
| * | |
| * @param {Argument} argument | |
| * @return {string} | |
| */ | |
| argumentDescription(argument) { | |
| const extraInfo = []; | |
| if (argument.argChoices) { | |
| extraInfo.push( | |
| // use stringify to match the display of the default value | |
| `choices: ${argument.argChoices.map((choice) => JSON.stringify(choice)).join(", ")}` | |
| ); | |
| } | |
| if (argument.defaultValue !== void 0) { | |
| extraInfo.push( | |
| `default: ${argument.defaultValueDescription || JSON.stringify(argument.defaultValue)}` | |
| ); | |
| } | |
| if (extraInfo.length > 0) { | |
| const extraDescripton = `(${extraInfo.join(", ")})`; | |
| if (argument.description) { | |
| return `${argument.description} ${extraDescripton}`; | |
| } | |
| return extraDescripton; | |
| } | |
| return argument.description; | |
| } | |
| /** | |
| * Generate the built-in help text. | |
| * | |
| * @param {Command} cmd | |
| * @param {Help} helper | |
| * @returns {string} | |
| */ | |
| formatHelp(cmd, helper) { | |
| const termWidth = helper.padWidth(cmd, helper); | |
| const helpWidth = helper.helpWidth || 80; | |
| const itemIndentWidth = 2; | |
| const itemSeparatorWidth = 2; | |
| function formatItem(term, description) { | |
| if (description) { | |
| const fullText = `${term.padEnd(termWidth + itemSeparatorWidth)}${description}`; | |
| return helper.wrap( | |
| fullText, | |
| helpWidth - itemIndentWidth, | |
| termWidth + itemSeparatorWidth | |
| ); | |
| } | |
| return term; | |
| } | |
| function formatList(textArray) { | |
| return textArray.join("\n").replace(/^/gm, " ".repeat(itemIndentWidth)); | |
| } | |
| let output = [`Usage: ${helper.commandUsage(cmd)}`, ""]; | |
| const commandDescription = helper.commandDescription(cmd); | |
| if (commandDescription.length > 0) { | |
| output = output.concat([ | |
| helper.wrap(commandDescription, helpWidth, 0), | |
| "" | |
| ]); | |
| } | |
| const argumentList = helper.visibleArguments(cmd).map((argument) => { | |
| return formatItem( | |
| helper.argumentTerm(argument), | |
| helper.argumentDescription(argument) | |
| ); | |
| }); | |
| if (argumentList.length > 0) { | |
| output = output.concat(["Arguments:", formatList(argumentList), ""]); | |
| } | |
| const optionList = helper.visibleOptions(cmd).map((option) => { | |
| return formatItem( | |
| helper.optionTerm(option), | |
| helper.optionDescription(option) | |
| ); | |
| }); | |
| if (optionList.length > 0) { | |
| output = output.concat(["Options:", formatList(optionList), ""]); | |
| } | |
| if (this.showGlobalOptions) { | |
| const globalOptionList = helper.visibleGlobalOptions(cmd).map((option) => { | |
| return formatItem( | |
| helper.optionTerm(option), | |
| helper.optionDescription(option) | |
| ); | |
| }); | |
| if (globalOptionList.length > 0) { | |
| output = output.concat([ | |
| "Global Options:", | |
| formatList(globalOptionList), | |
| "" | |
| ]); | |
| } | |
| } | |
| const commandList = helper.visibleCommands(cmd).map((cmd2) => { | |
| return formatItem( | |
| helper.subcommandTerm(cmd2), | |
| helper.subcommandDescription(cmd2) | |
| ); | |
| }); | |
| if (commandList.length > 0) { | |
| output = output.concat(["Commands:", formatList(commandList), ""]); | |
| } | |
| return output.join("\n"); | |
| } | |
| /** | |
| * Calculate the pad width from the maximum term length. | |
| * | |
| * @param {Command} cmd | |
| * @param {Help} helper | |
| * @returns {number} | |
| */ | |
| padWidth(cmd, helper) { | |
| return Math.max( | |
| helper.longestOptionTermLength(cmd, helper), | |
| helper.longestGlobalOptionTermLength(cmd, helper), | |
| helper.longestSubcommandTermLength(cmd, helper), | |
| helper.longestArgumentTermLength(cmd, helper) | |
| ); | |
| } | |
| /** | |
| * Wrap the given string to width characters per line, with lines after the first indented. | |
| * Do not wrap if insufficient room for wrapping (minColumnWidth), or string is manually formatted. | |
| * | |
| * @param {string} str | |
| * @param {number} width | |
| * @param {number} indent | |
| * @param {number} [minColumnWidth=40] | |
| * @return {string} | |
| * | |
| */ | |
| wrap(str, width, indent, minColumnWidth = 40) { | |
| const indents = " \\f\\t\\v\xA0\u1680\u2000-\u200A\u202F\u205F\u3000\uFEFF"; | |
| const manualIndent = new RegExp(`[\\n][${indents}]+`); | |
| if (str.match(manualIndent)) return str; | |
| const columnWidth = width - indent; | |
| if (columnWidth < minColumnWidth) return str; | |
| const leadingStr = str.slice(0, indent); | |
| const columnText = str.slice(indent).replace("\r\n", "\n"); | |
| const indentString = " ".repeat(indent); | |
| const zeroWidthSpace = "\u200B"; | |
| const breaks = `\\s${zeroWidthSpace}`; | |
| const regex = new RegExp( | |
| ` | |
| |.{1,${columnWidth - 1}}([${breaks}]|$)|[^${breaks}]+?([${breaks}]|$)`, | |
| "g" | |
| ); | |
| const lines = columnText.match(regex) || []; | |
| return leadingStr + lines.map((line, i) => { | |
| if (line === "\n") return ""; | |
| return (i > 0 ? indentString : "") + line.trimEnd(); | |
| }).join("\n"); | |
| } | |
| }; | |
| exports2.Help = Help2; | |
| } | |
| }); | |
| // node_modules/.pnpm/commander@12.1.0/node_modules/commander/lib/option.js | |
| var require_option = __commonJS({ | |
| "node_modules/.pnpm/commander@12.1.0/node_modules/commander/lib/option.js"(exports2) { | |
| var { InvalidArgumentError: InvalidArgumentError2 } = require_error(); | |
| var Option2 = class { | |
| /** | |
| * Initialize a new `Option` with the given `flags` and `description`. | |
| * | |
| * @param {string} flags | |
| * @param {string} [description] | |
| */ | |
| constructor(flags, description) { | |
| this.flags = flags; | |
| this.description = description || ""; | |
| this.required = flags.includes("<"); | |
| this.optional = flags.includes("["); | |
| this.variadic = /\w\.\.\.[>\]]$/.test(flags); | |
| this.mandatory = false; | |
| const optionFlags = splitOptionFlags(flags); | |
| this.short = optionFlags.shortFlag; | |
| this.long = optionFlags.longFlag; | |
| this.negate = false; | |
| if (this.long) { | |
| this.negate = this.long.startsWith("--no-"); | |
| } | |
| this.defaultValue = void 0; | |
| this.defaultValueDescription = void 0; | |
| this.presetArg = void 0; | |
| this.envVar = void 0; | |
| this.parseArg = void 0; | |
| this.hidden = false; | |
| this.argChoices = void 0; | |
| this.conflictsWith = []; | |
| this.implied = void 0; | |
| } | |
| /** | |
| * Set the default value, and optionally supply the description to be displayed in the help. | |
| * | |
| * @param {*} value | |
| * @param {string} [description] | |
| * @return {Option} | |
| */ | |
| default(value, description) { | |
| this.defaultValue = value; | |
| this.defaultValueDescription = description; | |
| return this; | |
| } | |
| /** | |
| * Preset to use when option used without option-argument, especially optional but also boolean and negated. | |
| * The custom processing (parseArg) is called. | |
| * | |
| * @example | |
| * new Option('--color').default('GREYSCALE').preset('RGB'); | |
| * new Option('--donate [amount]').preset('20').argParser(parseFloat); | |
| * | |
| * @param {*} arg | |
| * @return {Option} | |
| */ | |
| preset(arg) { | |
| this.presetArg = arg; | |
| return this; | |
| } | |
| /** | |
| * Add option name(s) that conflict with this option. | |
| * An error will be displayed if conflicting options are found during parsing. | |
| * | |
| * @example | |
| * new Option('--rgb').conflicts('cmyk'); | |
| * new Option('--js').conflicts(['ts', 'jsx']); | |
| * | |
| * @param {(string | string[])} names | |
| * @return {Option} | |
| */ | |
| conflicts(names) { | |
| this.conflictsWith = this.conflictsWith.concat(names); | |
| return this; | |
| } | |
| /** | |
| * Specify implied option values for when this option is set and the implied options are not. | |
| * | |
| * The custom processing (parseArg) is not called on the implied values. | |
| * | |
| * @example | |
| * program | |
| * .addOption(new Option('--log', 'write logging information to file')) | |
| * .addOption(new Option('--trace', 'log extra details').implies({ log: 'trace.txt' })); | |
| * | |
| * @param {object} impliedOptionValues | |
| * @return {Option} | |
| */ | |
| implies(impliedOptionValues) { | |
| let newImplied = impliedOptionValues; | |
| if (typeof impliedOptionValues === "string") { | |
| newImplied = { [impliedOptionValues]: true }; | |
| } | |
| this.implied = Object.assign(this.implied || {}, newImplied); | |
| return this; | |
| } | |
| /** | |
| * Set environment variable to check for option value. | |
| * | |
| * An environment variable is only used if when processed the current option value is | |
| * undefined, or the source of the current value is 'default' or 'config' or 'env'. | |
| * | |
| * @param {string} name | |
| * @return {Option} | |
| */ | |
| env(name) { | |
| this.envVar = name; | |
| return this; | |
| } | |
| /** | |
| * Set the custom handler for processing CLI option arguments into option values. | |
| * | |
| * @param {Function} [fn] | |
| * @return {Option} | |
| */ | |
| argParser(fn) { | |
| this.parseArg = fn; | |
| return this; | |
| } | |
| /** | |
| * Whether the option is mandatory and must have a value after parsing. | |
| * | |
| * @param {boolean} [mandatory=true] | |
| * @return {Option} | |
| */ | |
| makeOptionMandatory(mandatory = true) { | |
| this.mandatory = !!mandatory; | |
| return this; | |
| } | |
| /** | |
| * Hide option in help. | |
| * | |
| * @param {boolean} [hide=true] | |
| * @return {Option} | |
| */ | |
| hideHelp(hide = true) { | |
| this.hidden = !!hide; | |
| return this; | |
| } | |
| /** | |
| * @package | |
| */ | |
| _concatValue(value, previous) { | |
| if (previous === this.defaultValue || !Array.isArray(previous)) { | |
| return [value]; | |
| } | |
| return previous.concat(value); | |
| } | |
| /** | |
| * Only allow option value to be one of choices. | |
| * | |
| * @param {string[]} values | |
| * @return {Option} | |
| */ | |
| choices(values) { | |
| this.argChoices = values.slice(); | |
| this.parseArg = (arg, previous) => { | |
| if (!this.argChoices.includes(arg)) { | |
| throw new InvalidArgumentError2( | |
| `Allowed choices are ${this.argChoices.join(", ")}.` | |
| ); | |
| } | |
| if (this.variadic) { | |
| return this._concatValue(arg, previous); | |
| } | |
| return arg; | |
| }; | |
| return this; | |
| } | |
| /** | |
| * Return option name. | |
| * | |
| * @return {string} | |
| */ | |
| name() { | |
| if (this.long) { | |
| return this.long.replace(/^--/, ""); | |
| } | |
| return this.short.replace(/^-/, ""); | |
| } | |
| /** | |
| * Return option name, in a camelcase format that can be used | |
| * as a object attribute key. | |
| * | |
| * @return {string} | |
| */ | |
| attributeName() { | |
| return camelcase(this.name().replace(/^no-/, "")); | |
| } | |
| /** | |
| * Check if `arg` matches the short or long flag. | |
| * | |
| * @param {string} arg | |
| * @return {boolean} | |
| * @package | |
| */ | |
| is(arg) { | |
| return this.short === arg || this.long === arg; | |
| } | |
| /** | |
| * Return whether a boolean option. | |
| * | |
| * Options are one of boolean, negated, required argument, or optional argument. | |
| * | |
| * @return {boolean} | |
| * @package | |
| */ | |
| isBoolean() { | |
| return !this.required && !this.optional && !this.negate; | |
| } | |
| }; | |
| var DualOptions = class { | |
| /** | |
| * @param {Option[]} options | |
| */ | |
| constructor(options) { | |
| this.positiveOptions = /* @__PURE__ */ new Map(); | |
| this.negativeOptions = /* @__PURE__ */ new Map(); | |
| this.dualOptions = /* @__PURE__ */ new Set(); | |
| options.forEach((option) => { | |
| if (option.negate) { | |
| this.negativeOptions.set(option.attributeName(), option); | |
| } else { | |
| this.positiveOptions.set(option.attributeName(), option); | |
| } | |
| }); | |
| this.negativeOptions.forEach((value, key) => { | |
| if (this.positiveOptions.has(key)) { | |
| this.dualOptions.add(key); | |
| } | |
| }); | |
| } | |
| /** | |
| * Did the value come from the option, and not from possible matching dual option? | |
| * | |
| * @param {*} value | |
| * @param {Option} option | |
| * @returns {boolean} | |
| */ | |
| valueFromOption(value, option) { | |
| const optionKey = option.attributeName(); | |
| if (!this.dualOptions.has(optionKey)) return true; | |
| const preset = this.negativeOptions.get(optionKey).presetArg; | |
| const negativeValue = preset !== void 0 ? preset : false; | |
| return option.negate === (negativeValue === value); | |
| } | |
| }; | |
| function camelcase(str) { | |
| return str.split("-").reduce((str2, word) => { | |
| return str2 + word[0].toUpperCase() + word.slice(1); | |
| }); | |
| } | |
| function splitOptionFlags(flags) { | |
| let shortFlag; | |
| let longFlag; | |
| const flagParts = flags.split(/[ |,]+/); | |
| if (flagParts.length > 1 && !/^[[<]/.test(flagParts[1])) | |
| shortFlag = flagParts.shift(); | |
| longFlag = flagParts.shift(); | |
| if (!shortFlag && /^-[^-]$/.test(longFlag)) { | |
| shortFlag = longFlag; | |
| longFlag = void 0; | |
| } | |
| return { shortFlag, longFlag }; | |
| } | |
| exports2.Option = Option2; | |
| exports2.DualOptions = DualOptions; | |
| } | |
| }); | |
| // node_modules/.pnpm/commander@12.1.0/node_modules/commander/lib/suggestSimilar.js | |
| var require_suggestSimilar = __commonJS({ | |
| "node_modules/.pnpm/commander@12.1.0/node_modules/commander/lib/suggestSimilar.js"(exports2) { | |
| var maxDistance = 3; | |
| function editDistance(a, b) { | |
| if (Math.abs(a.length - b.length) > maxDistance) | |
| return Math.max(a.length, b.length); | |
| const d = []; | |
| for (let i = 0; i <= a.length; i++) { | |
| d[i] = [i]; | |
| } | |
| for (let j = 0; j <= b.length; j++) { | |
| d[0][j] = j; | |
| } | |
| for (let j = 1; j <= b.length; j++) { | |
| for (let i = 1; i <= a.length; i++) { | |
| let cost = 1; | |
| if (a[i - 1] === b[j - 1]) { | |
| cost = 0; | |
| } else { | |
| cost = 1; | |
| } | |
| d[i][j] = Math.min( | |
| d[i - 1][j] + 1, | |
| // deletion | |
| d[i][j - 1] + 1, | |
| // insertion | |
| d[i - 1][j - 1] + cost | |
| // substitution | |
| ); | |
| if (i > 1 && j > 1 && a[i - 1] === b[j - 2] && a[i - 2] === b[j - 1]) { | |
| d[i][j] = Math.min(d[i][j], d[i - 2][j - 2] + 1); | |
| } | |
| } | |
| } | |
| return d[a.length][b.length]; | |
| } | |
| function suggestSimilar(word, candidates) { | |
| if (!candidates || candidates.length === 0) return ""; | |
| candidates = Array.from(new Set(candidates)); | |
| const searchingOptions = word.startsWith("--"); | |
| if (searchingOptions) { | |
| word = word.slice(2); | |
| candidates = candidates.map((candidate) => candidate.slice(2)); | |
| } | |
| let similar = []; | |
| let bestDistance = maxDistance; | |
| const minSimilarity = 0.4; | |
| candidates.forEach((candidate) => { | |
| if (candidate.length <= 1) return; | |
| const distance = editDistance(word, candidate); | |
| const length = Math.max(word.length, candidate.length); | |
| const similarity = (length - distance) / length; | |
| if (similarity > minSimilarity) { | |
| if (distance < bestDistance) { | |
| bestDistance = distance; | |
| similar = [candidate]; | |
| } else if (distance === bestDistance) { | |
| similar.push(candidate); | |
| } | |
| } | |
| }); | |
| similar.sort((a, b) => a.localeCompare(b)); | |
| if (searchingOptions) { | |
| similar = similar.map((candidate) => `--${candidate}`); | |
| } | |
| if (similar.length > 1) { | |
| return ` | |
| (Did you mean one of ${similar.join(", ")}?)`; | |
| } | |
| if (similar.length === 1) { | |
| return ` | |
| (Did you mean ${similar[0]}?)`; | |
| } | |
| return ""; | |
| } | |
| exports2.suggestSimilar = suggestSimilar; | |
| } | |
| }); | |
| // node_modules/.pnpm/commander@12.1.0/node_modules/commander/lib/command.js | |
| var require_command = __commonJS({ | |
| "node_modules/.pnpm/commander@12.1.0/node_modules/commander/lib/command.js"(exports2) { | |
| var EventEmitter = require("node:events").EventEmitter; | |
| var childProcess = require("node:child_process"); | |
| var path = require("node:path"); | |
| var fs = require("node:fs"); | |
| var process2 = require("node:process"); | |
| var { Argument: Argument2, humanReadableArgName } = require_argument(); | |
| var { CommanderError: CommanderError2 } = require_error(); | |
| var { Help: Help2 } = require_help(); | |
| var { Option: Option2, DualOptions } = require_option(); | |
| var { suggestSimilar } = require_suggestSimilar(); | |
| var Command2 = class _Command extends EventEmitter { | |
| /** | |
| * Initialize a new `Command`. | |
| * | |
| * @param {string} [name] | |
| */ | |
| constructor(name) { | |
| super(); | |
| this.commands = []; | |
| this.options = []; | |
| this.parent = null; | |
| this._allowUnknownOption = false; | |
| this._allowExcessArguments = true; | |
| this.registeredArguments = []; | |
| this._args = this.registeredArguments; | |
| this.args = []; | |
| this.rawArgs = []; | |
| this.processedArgs = []; | |
| this._scriptPath = null; | |
| this._name = name || ""; | |
| this._optionValues = {}; | |
| this._optionValueSources = {}; | |
| this._storeOptionsAsProperties = false; | |
| this._actionHandler = null; | |
| this._executableHandler = false; | |
| this._executableFile = null; | |
| this._executableDir = null; | |
| this._defaultCommandName = null; | |
| this._exitCallback = null; | |
| this._aliases = []; | |
| this._combineFlagAndOptionalValue = true; | |
| this._description = ""; | |
| this._summary = ""; | |
| this._argsDescription = void 0; | |
| this._enablePositionalOptions = false; | |
| this._passThroughOptions = false; | |
| this._lifeCycleHooks = {}; | |
| this._showHelpAfterError = false; | |
| this._showSuggestionAfterError = true; | |
| this._outputConfiguration = { | |
| writeOut: (str) => process2.stdout.write(str), | |
| writeErr: (str) => process2.stderr.write(str), | |
| getOutHelpWidth: () => process2.stdout.isTTY ? process2.stdout.columns : void 0, | |
| getErrHelpWidth: () => process2.stderr.isTTY ? process2.stderr.columns : void 0, | |
| outputError: (str, write) => write(str) | |
| }; | |
| this._hidden = false; | |
| this._helpOption = void 0; | |
| this._addImplicitHelpCommand = void 0; | |
| this._helpCommand = void 0; | |
| this._helpConfiguration = {}; | |
| } | |
| /** | |
| * Copy settings that are useful to have in common across root command and subcommands. | |
| * | |
| * (Used internally when adding a command using `.command()` so subcommands inherit parent settings.) | |
| * | |
| * @param {Command} sourceCommand | |
| * @return {Command} `this` command for chaining | |
| */ | |
| copyInheritedSettings(sourceCommand) { | |
| this._outputConfiguration = sourceCommand._outputConfiguration; | |
| this._helpOption = sourceCommand._helpOption; | |
| this._helpCommand = sourceCommand._helpCommand; | |
| this._helpConfiguration = sourceCommand._helpConfiguration; | |
| this._exitCallback = sourceCommand._exitCallback; | |
| this._storeOptionsAsProperties = sourceCommand._storeOptionsAsProperties; | |
| this._combineFlagAndOptionalValue = sourceCommand._combineFlagAndOptionalValue; | |
| this._allowExcessArguments = sourceCommand._allowExcessArguments; | |
| this._enablePositionalOptions = sourceCommand._enablePositionalOptions; | |
| this._showHelpAfterError = sourceCommand._showHelpAfterError; | |
| this._showSuggestionAfterError = sourceCommand._showSuggestionAfterError; | |
| return this; | |
| } | |
| /** | |
| * @returns {Command[]} | |
| * @private | |
| */ | |
| _getCommandAndAncestors() { | |
| const result = []; | |
| for (let command = this; command; command = command.parent) { | |
| result.push(command); | |
| } | |
| return result; | |
| } | |
| /** | |
| * Define a command. | |
| * | |
| * There are two styles of command: pay attention to where to put the description. | |
| * | |
| * @example | |
| * // Command implemented using action handler (description is supplied separately to `.command`) | |
| * program | |
| * .command('clone <source> [destination]') | |
| * .description('clone a repository into a newly created directory') | |
| * .action((source, destination) => { | |
| * console.log('clone command called'); | |
| * }); | |
| * | |
| * // Command implemented using separate executable file (description is second parameter to `.command`) | |
| * program | |
| * .command('start <service>', 'start named service') | |
| * .command('stop [service]', 'stop named service, or all if no name supplied'); | |
| * | |
| * @param {string} nameAndArgs - command name and arguments, args are `<required>` or `[optional]` and last may also be `variadic...` | |
| * @param {(object | string)} [actionOptsOrExecDesc] - configuration options (for action), or description (for executable) | |
| * @param {object} [execOpts] - configuration options (for executable) | |
| * @return {Command} returns new command for action handler, or `this` for executable command | |
| */ | |
| command(nameAndArgs, actionOptsOrExecDesc, execOpts) { | |
| let desc = actionOptsOrExecDesc; | |
| let opts = execOpts; | |
| if (typeof desc === "object" && desc !== null) { | |
| opts = desc; | |
| desc = null; | |
| } | |
| opts = opts || {}; | |
| const [, name, args] = nameAndArgs.match(/([^ ]+) *(.*)/); | |
| const cmd = this.createCommand(name); | |
| if (desc) { | |
| cmd.description(desc); | |
| cmd._executableHandler = true; | |
| } | |
| if (opts.isDefault) this._defaultCommandName = cmd._name; | |
| cmd._hidden = !!(opts.noHelp || opts.hidden); | |
| cmd._executableFile = opts.executableFile || null; | |
| if (args) cmd.arguments(args); | |
| this._registerCommand(cmd); | |
| cmd.parent = this; | |
| cmd.copyInheritedSettings(this); | |
| if (desc) return this; | |
| return cmd; | |
| } | |
| /** | |
| * Factory routine to create a new unattached command. | |
| * | |
| * See .command() for creating an attached subcommand, which uses this routine to | |
| * create the command. You can override createCommand to customise subcommands. | |
| * | |
| * @param {string} [name] | |
| * @return {Command} new command | |
| */ | |
| createCommand(name) { | |
| return new _Command(name); | |
| } | |
| /** | |
| * You can customise the help with a subclass of Help by overriding createHelp, | |
| * or by overriding Help properties using configureHelp(). | |
| * | |
| * @return {Help} | |
| */ | |
| createHelp() { | |
| return Object.assign(new Help2(), this.configureHelp()); | |
| } | |
| /** | |
| * You can customise the help by overriding Help properties using configureHelp(), | |
| * or with a subclass of Help by overriding createHelp(). | |
| * | |
| * @param {object} [configuration] - configuration options | |
| * @return {(Command | object)} `this` command for chaining, or stored configuration | |
| */ | |
| configureHelp(configuration) { | |
| if (configuration === void 0) return this._helpConfiguration; | |
| this._helpConfiguration = configuration; | |
| return this; | |
| } | |
| /** | |
| * The default output goes to stdout and stderr. You can customise this for special | |
| * applications. You can also customise the display of errors by overriding outputError. | |
| * | |
| * The configuration properties are all functions: | |
| * | |
| * // functions to change where being written, stdout and stderr | |
| * writeOut(str) | |
| * writeErr(str) | |
| * // matching functions to specify width for wrapping help | |
| * getOutHelpWidth() | |
| * getErrHelpWidth() | |
| * // functions based on what is being written out | |
| * outputError(str, write) // used for displaying errors, and not used for displaying help | |
| * | |
| * @param {object} [configuration] - configuration options | |
| * @return {(Command | object)} `this` command for chaining, or stored configuration | |
| */ | |
| configureOutput(configuration) { | |
| if (configuration === void 0) return this._outputConfiguration; | |
| Object.assign(this._outputConfiguration, configuration); | |
| return this; | |
| } | |
| /** | |
| * Display the help or a custom message after an error occurs. | |
| * | |
| * @param {(boolean|string)} [displayHelp] | |
| * @return {Command} `this` command for chaining | |
| */ | |
| showHelpAfterError(displayHelp = true) { | |
| if (typeof displayHelp !== "string") displayHelp = !!displayHelp; | |
| this._showHelpAfterError = displayHelp; | |
| return this; | |
| } | |
| /** | |
| * Display suggestion of similar commands for unknown commands, or options for unknown options. | |
| * | |
| * @param {boolean} [displaySuggestion] | |
| * @return {Command} `this` command for chaining | |
| */ | |
| showSuggestionAfterError(displaySuggestion = true) { | |
| this._showSuggestionAfterError = !!displaySuggestion; | |
| return this; | |
| } | |
| /** | |
| * Add a prepared subcommand. | |
| * | |
| * See .command() for creating an attached subcommand which inherits settings from its parent. | |
| * | |
| * @param {Command} cmd - new subcommand | |
| * @param {object} [opts] - configuration options | |
| * @return {Command} `this` command for chaining | |
| */ | |
| addCommand(cmd, opts) { | |
| if (!cmd._name) { | |
| throw new Error(`Command passed to .addCommand() must have a name | |
| - specify the name in Command constructor or using .name()`); | |
| } | |
| opts = opts || {}; | |
| if (opts.isDefault) this._defaultCommandName = cmd._name; | |
| if (opts.noHelp || opts.hidden) cmd._hidden = true; | |
| this._registerCommand(cmd); | |
| cmd.parent = this; | |
| cmd._checkForBrokenPassThrough(); | |
| return this; | |
| } | |
| /** | |
| * Factory routine to create a new unattached argument. | |
| * | |
| * See .argument() for creating an attached argument, which uses this routine to | |
| * create the argument. You can override createArgument to return a custom argument. | |
| * | |
| * @param {string} name | |
| * @param {string} [description] | |
| * @return {Argument} new argument | |
| */ | |
| createArgument(name, description) { | |
| return new Argument2(name, description); | |
| } | |
| /** | |
| * Define argument syntax for command. | |
| * | |
| * The default is that the argument is required, and you can explicitly | |
| * indicate this with <> around the name. Put [] around the name for an optional argument. | |
| * | |
| * @example | |
| * program.argument('<input-file>'); | |
| * program.argument('[output-file]'); | |
| * | |
| * @param {string} name | |
| * @param {string} [description] | |
| * @param {(Function|*)} [fn] - custom argument processing function | |
| * @param {*} [defaultValue] | |
| * @return {Command} `this` command for chaining | |
| */ | |
| argument(name, description, fn, defaultValue) { | |
| const argument = this.createArgument(name, description); | |
| if (typeof fn === "function") { | |
| argument.default(defaultValue).argParser(fn); | |
| } else { | |
| argument.default(fn); | |
| } | |
| this.addArgument(argument); | |
| return this; | |
| } | |
| /** | |
| * Define argument syntax for command, adding multiple at once (without descriptions). | |
| * | |
| * See also .argument(). | |
| * | |
| * @example | |
| * program.arguments('<cmd> [env]'); | |
| * | |
| * @param {string} names | |
| * @return {Command} `this` command for chaining | |
| */ | |
| arguments(names) { | |
| names.trim().split(/ +/).forEach((detail) => { | |
| this.argument(detail); | |
| }); | |
| return this; | |
| } | |
| /** | |
| * Define argument syntax for command, adding a prepared argument. | |
| * | |
| * @param {Argument} argument | |
| * @return {Command} `this` command for chaining | |
| */ | |
| addArgument(argument) { | |
| const previousArgument = this.registeredArguments.slice(-1)[0]; | |
| if (previousArgument && previousArgument.variadic) { | |
| throw new Error( | |
| `only the last argument can be variadic '${previousArgument.name()}'` | |
| ); | |
| } | |
| if (argument.required && argument.defaultValue !== void 0 && argument.parseArg === void 0) { | |
| throw new Error( | |
| `a default value for a required argument is never used: '${argument.name()}'` | |
| ); | |
| } | |
| this.registeredArguments.push(argument); | |
| return this; | |
| } | |
| /** | |
| * Customise or override default help command. By default a help command is automatically added if your command has subcommands. | |
| * | |
| * @example | |
| * program.helpCommand('help [cmd]'); | |
| * program.helpCommand('help [cmd]', 'show help'); | |
| * program.helpCommand(false); // suppress default help command | |
| * program.helpCommand(true); // add help command even if no subcommands | |
| * | |
| * @param {string|boolean} enableOrNameAndArgs - enable with custom name and/or arguments, or boolean to override whether added | |
| * @param {string} [description] - custom description | |
| * @return {Command} `this` command for chaining | |
| */ | |
| helpCommand(enableOrNameAndArgs, description) { | |
| if (typeof enableOrNameAndArgs === "boolean") { | |
| this._addImplicitHelpCommand = enableOrNameAndArgs; | |
| return this; | |
| } | |
| enableOrNameAndArgs = enableOrNameAndArgs ?? "help [command]"; | |
| const [, helpName, helpArgs] = enableOrNameAndArgs.match(/([^ ]+) *(.*)/); | |
| const helpDescription = description ?? "display help for command"; | |
| const helpCommand = this.createCommand(helpName); | |
| helpCommand.helpOption(false); | |
| if (helpArgs) helpCommand.arguments(helpArgs); | |
| if (helpDescription) helpCommand.description(helpDescription); | |
| this._addImplicitHelpCommand = true; | |
| this._helpCommand = helpCommand; | |
| return this; | |
| } | |
| /** | |
| * Add prepared custom help command. | |
| * | |
| * @param {(Command|string|boolean)} helpCommand - custom help command, or deprecated enableOrNameAndArgs as for `.helpCommand()` | |
| * @param {string} [deprecatedDescription] - deprecated custom description used with custom name only | |
| * @return {Command} `this` command for chaining | |
| */ | |
| addHelpCommand(helpCommand, deprecatedDescription) { | |
| if (typeof helpCommand !== "object") { | |
| this.helpCommand(helpCommand, deprecatedDescription); | |
| return this; | |
| } | |
| this._addImplicitHelpCommand = true; | |
| this._helpCommand = helpCommand; | |
| return this; | |
| } | |
| /** | |
| * Lazy create help command. | |
| * | |
| * @return {(Command|null)} | |
| * @package | |
| */ | |
| _getHelpCommand() { | |
| const hasImplicitHelpCommand = this._addImplicitHelpCommand ?? (this.commands.length && !this._actionHandler && !this._findCommand("help")); | |
| if (hasImplicitHelpCommand) { | |
| if (this._helpCommand === void 0) { | |
| this.helpCommand(void 0, void 0); | |
| } | |
| return this._helpCommand; | |
| } | |
| return null; | |
| } | |
| /** | |
| * Add hook for life cycle event. | |
| * | |
| * @param {string} event | |
| * @param {Function} listener | |
| * @return {Command} `this` command for chaining | |
| */ | |
| hook(event, listener) { | |
| const allowedValues = ["preSubcommand", "preAction", "postAction"]; | |
| if (!allowedValues.includes(event)) { | |
| throw new Error(`Unexpected value for event passed to hook : '${event}'. | |
| Expecting one of '${allowedValues.join("', '")}'`); | |
| } | |
| if (this._lifeCycleHooks[event]) { | |
| this._lifeCycleHooks[event].push(listener); | |
| } else { | |
| this._lifeCycleHooks[event] = [listener]; | |
| } | |
| return this; | |
| } | |
| /** | |
| * Register callback to use as replacement for calling process.exit. | |
| * | |
| * @param {Function} [fn] optional callback which will be passed a CommanderError, defaults to throwing | |
| * @return {Command} `this` command for chaining | |
| */ | |
| exitOverride(fn) { | |
| if (fn) { | |
| this._exitCallback = fn; | |
| } else { | |
| this._exitCallback = (err) => { | |
| if (err.code !== "commander.executeSubCommandAsync") { | |
| throw err; | |
| } else { | |
| } | |
| }; | |
| } | |
| return this; | |
| } | |
| /** | |
| * Call process.exit, and _exitCallback if defined. | |
| * | |
| * @param {number} exitCode exit code for using with process.exit | |
| * @param {string} code an id string representing the error | |
| * @param {string} message human-readable description of the error | |
| * @return never | |
| * @private | |
| */ | |
| _exit(exitCode, code, message) { | |
| if (this._exitCallback) { | |
| this._exitCallback(new CommanderError2(exitCode, code, message)); | |
| } | |
| process2.exit(exitCode); | |
| } | |
| /** | |
| * Register callback `fn` for the command. | |
| * | |
| * @example | |
| * program | |
| * .command('serve') | |
| * .description('start service') | |
| * .action(function() { | |
| * // do work here | |
| * }); | |
| * | |
| * @param {Function} fn | |
| * @return {Command} `this` command for chaining | |
| */ | |
| action(fn) { | |
| const listener = (args) => { | |
| const expectedArgsCount = this.registeredArguments.length; | |
| const actionArgs = args.slice(0, expectedArgsCount); | |
| if (this._storeOptionsAsProperties) { | |
| actionArgs[expectedArgsCount] = this; | |
| } else { | |
| actionArgs[expectedArgsCount] = this.opts(); | |
| } | |
| actionArgs.push(this); | |
| return fn.apply(this, actionArgs); | |
| }; | |
| this._actionHandler = listener; | |
| return this; | |
| } | |
| /** | |
| * Factory routine to create a new unattached option. | |
| * | |
| * See .option() for creating an attached option, which uses this routine to | |
| * create the option. You can override createOption to return a custom option. | |
| * | |
| * @param {string} flags | |
| * @param {string} [description] | |
| * @return {Option} new option | |
| */ | |
| createOption(flags, description) { | |
| return new Option2(flags, description); | |
| } | |
| /** | |
| * Wrap parseArgs to catch 'commander.invalidArgument'. | |
| * | |
| * @param {(Option | Argument)} target | |
| * @param {string} value | |
| * @param {*} previous | |
| * @param {string} invalidArgumentMessage | |
| * @private | |
| */ | |
| _callParseArg(target, value, previous, invalidArgumentMessage) { | |
| try { | |
| return target.parseArg(value, previous); | |
| } catch (err) { | |
| if (err.code === "commander.invalidArgument") { | |
| const message = `${invalidArgumentMessage} ${err.message}`; | |
| this.error(message, { exitCode: err.exitCode, code: err.code }); | |
| } | |
| throw err; | |
| } | |
| } | |
| /** | |
| * Check for option flag conflicts. | |
| * Register option if no conflicts found, or throw on conflict. | |
| * | |
| * @param {Option} option | |
| * @private | |
| */ | |
| _registerOption(option) { | |
| const matchingOption = option.short && this._findOption(option.short) || option.long && this._findOption(option.long); | |
| if (matchingOption) { | |
| const matchingFlag = option.long && this._findOption(option.long) ? option.long : option.short; | |
| throw new Error(`Cannot add option '${option.flags}'${this._name && ` to command '${this._name}'`} due to conflicting flag '${matchingFlag}' | |
| - already used by option '${matchingOption.flags}'`); | |
| } | |
| this.options.push(option); | |
| } | |
| /** | |
| * Check for command name and alias conflicts with existing commands. | |
| * Register command if no conflicts found, or throw on conflict. | |
| * | |
| * @param {Command} command | |
| * @private | |
| */ | |
| _registerCommand(command) { | |
| const knownBy = (cmd) => { | |
| return [cmd.name()].concat(cmd.aliases()); | |
| }; | |
| const alreadyUsed = knownBy(command).find( | |
| (name) => this._findCommand(name) | |
| ); | |
| if (alreadyUsed) { | |
| const existingCmd = knownBy(this._findCommand(alreadyUsed)).join("|"); | |
| const newCmd = knownBy(command).join("|"); | |
| throw new Error( | |
| `cannot add command '${newCmd}' as already have command '${existingCmd}'` | |
| ); | |
| } | |
| this.commands.push(command); | |
| } | |
| /** | |
| * Add an option. | |
| * | |
| * @param {Option} option | |
| * @return {Command} `this` command for chaining | |
| */ | |
| addOption(option) { | |
| this._registerOption(option); | |
| const oname = option.name(); | |
| const name = option.attributeName(); | |
| if (option.negate) { | |
| const positiveLongFlag = option.long.replace(/^--no-/, "--"); | |
| if (!this._findOption(positiveLongFlag)) { | |
| this.setOptionValueWithSource( | |
| name, | |
| option.defaultValue === void 0 ? true : option.defaultValue, | |
| "default" | |
| ); | |
| } | |
| } else if (option.defaultValue !== void 0) { | |
| this.setOptionValueWithSource(name, option.defaultValue, "default"); | |
| } | |
| const handleOptionValue = (val, invalidValueMessage, valueSource) => { | |
| if (val == null && option.presetArg !== void 0) { | |
| val = option.presetArg; | |
| } | |
| const oldValue = this.getOptionValue(name); | |
| if (val !== null && option.parseArg) { | |
| val = this._callParseArg(option, val, oldValue, invalidValueMessage); | |
| } else if (val !== null && option.variadic) { | |
| val = option._concatValue(val, oldValue); | |
| } | |
| if (val == null) { | |
| if (option.negate) { | |
| val = false; | |
| } else if (option.isBoolean() || option.optional) { | |
| val = true; | |
| } else { | |
| val = ""; | |
| } | |
| } | |
| this.setOptionValueWithSource(name, val, valueSource); | |
| }; | |
| this.on("option:" + oname, (val) => { | |
| const invalidValueMessage = `error: option '${option.flags}' argument '${val}' is invalid.`; | |
| handleOptionValue(val, invalidValueMessage, "cli"); | |
| }); | |
| if (option.envVar) { | |
| this.on("optionEnv:" + oname, (val) => { | |
| const invalidValueMessage = `error: option '${option.flags}' value '${val}' from env '${option.envVar}' is invalid.`; | |
| handleOptionValue(val, invalidValueMessage, "env"); | |
| }); | |
| } | |
| return this; | |
| } | |
| /** | |
| * Internal implementation shared by .option() and .requiredOption() | |
| * | |
| * @return {Command} `this` command for chaining | |
| * @private | |
| */ | |
| _optionEx(config, flags, description, fn, defaultValue) { | |
| if (typeof flags === "object" && flags instanceof Option2) { | |
| throw new Error( | |
| "To add an Option object use addOption() instead of option() or requiredOption()" | |
| ); | |
| } | |
| const option = this.createOption(flags, description); | |
| option.makeOptionMandatory(!!config.mandatory); | |
| if (typeof fn === "function") { | |
| option.default(defaultValue).argParser(fn); | |
| } else if (fn instanceof RegExp) { | |
| const regex = fn; | |
| fn = (val, def) => { | |
| const m = regex.exec(val); | |
| return m ? m[0] : def; | |
| }; | |
| option.default(defaultValue).argParser(fn); | |
| } else { | |
| option.default(fn); | |
| } | |
| return this.addOption(option); | |
| } | |
| /** | |
| * Define option with `flags`, `description`, and optional argument parsing function or `defaultValue` or both. | |
| * | |
| * The `flags` string contains the short and/or long flags, separated by comma, a pipe or space. A required | |
| * option-argument is indicated by `<>` and an optional option-argument by `[]`. | |
| * | |
| * See the README for more details, and see also addOption() and requiredOption(). | |
| * | |
| * @example | |
| * program | |
| * .option('-p, --pepper', 'add pepper') | |
| * .option('-p, --pizza-type <TYPE>', 'type of pizza') // required option-argument | |
| * .option('-c, --cheese [CHEESE]', 'add extra cheese', 'mozzarella') // optional option-argument with default | |
| * .option('-t, --tip <VALUE>', 'add tip to purchase cost', parseFloat) // custom parse function | |
| * | |
| * @param {string} flags | |
| * @param {string} [description] | |
| * @param {(Function|*)} [parseArg] - custom option processing function or default value | |
| * @param {*} [defaultValue] | |
| * @return {Command} `this` command for chaining | |
| */ | |
| option(flags, description, parseArg, defaultValue) { | |
| return this._optionEx({}, flags, description, parseArg, defaultValue); | |
| } | |
| /** | |
| * Add a required option which must have a value after parsing. This usually means | |
| * the option must be specified on the command line. (Otherwise the same as .option().) | |
| * | |
| * The `flags` string contains the short and/or long flags, separated by comma, a pipe or space. | |
| * | |
| * @param {string} flags | |
| * @param {string} [description] | |
| * @param {(Function|*)} [parseArg] - custom option processing function or default value | |
| * @param {*} [defaultValue] | |
| * @return {Command} `this` command for chaining | |
| */ | |
| requiredOption(flags, description, parseArg, defaultValue) { | |
| return this._optionEx( | |
| { mandatory: true }, | |
| flags, | |
| description, | |
| parseArg, | |
| defaultValue | |
| ); | |
| } | |
| /** | |
| * Alter parsing of short flags with optional values. | |
| * | |
| * @example | |
| * // for `.option('-f,--flag [value]'): | |
| * program.combineFlagAndOptionalValue(true); // `-f80` is treated like `--flag=80`, this is the default behaviour | |
| * program.combineFlagAndOptionalValue(false) // `-fb` is treated like `-f -b` | |
| * | |
| * @param {boolean} [combine] - if `true` or omitted, an optional value can be specified directly after the flag. | |
| * @return {Command} `this` command for chaining | |
| */ | |
| combineFlagAndOptionalValue(combine = true) { | |
| this._combineFlagAndOptionalValue = !!combine; | |
| return this; | |
| } | |
| /** | |
| * Allow unknown options on the command line. | |
| * | |
| * @param {boolean} [allowUnknown] - if `true` or omitted, no error will be thrown for unknown options. | |
| * @return {Command} `this` command for chaining | |
| */ | |
| allowUnknownOption(allowUnknown = true) { | |
| this._allowUnknownOption = !!allowUnknown; | |
| return this; | |
| } | |
| /** | |
| * Allow excess command-arguments on the command line. Pass false to make excess arguments an error. | |
| * | |
| * @param {boolean} [allowExcess] - if `true` or omitted, no error will be thrown for excess arguments. | |
| * @return {Command} `this` command for chaining | |
| */ | |
| allowExcessArguments(allowExcess = true) { | |
| this._allowExcessArguments = !!allowExcess; | |
| return this; | |
| } | |
| /** | |
| * Enable positional options. Positional means global options are specified before subcommands which lets | |
| * subcommands reuse the same option names, and also enables subcommands to turn on passThroughOptions. | |
| * The default behaviour is non-positional and global options may appear anywhere on the command line. | |
| * | |
| * @param {boolean} [positional] | |
| * @return {Command} `this` command for chaining | |
| */ | |
| enablePositionalOptions(positional = true) { | |
| this._enablePositionalOptions = !!positional; | |
| return this; | |
| } | |
| /** | |
| * Pass through options that come after command-arguments rather than treat them as command-options, | |
| * so actual command-options come before command-arguments. Turning this on for a subcommand requires | |
| * positional options to have been enabled on the program (parent commands). | |
| * The default behaviour is non-positional and options may appear before or after command-arguments. | |
| * | |
| * @param {boolean} [passThrough] for unknown options. | |
| * @return {Command} `this` command for chaining | |
| */ | |
| passThroughOptions(passThrough = true) { | |
| this._passThroughOptions = !!passThrough; | |
| this._checkForBrokenPassThrough(); | |
| return this; | |
| } | |
| /** | |
| * @private | |
| */ | |
| _checkForBrokenPassThrough() { | |
| if (this.parent && this._passThroughOptions && !this.parent._enablePositionalOptions) { | |
| throw new Error( | |
| `passThroughOptions cannot be used for '${this._name}' without turning on enablePositionalOptions for parent command(s)` | |
| ); | |
| } | |
| } | |
| /** | |
| * Whether to store option values as properties on command object, | |
| * or store separately (specify false). In both cases the option values can be accessed using .opts(). | |
| * | |
| * @param {boolean} [storeAsProperties=true] | |
| * @return {Command} `this` command for chaining | |
| */ | |
| storeOptionsAsProperties(storeAsProperties = true) { | |
| if (this.options.length) { | |
| throw new Error("call .storeOptionsAsProperties() before adding options"); | |
| } | |
| if (Object.keys(this._optionValues).length) { | |
| throw new Error( | |
| "call .storeOptionsAsProperties() before setting option values" | |
| ); | |
| } | |
| this._storeOptionsAsProperties = !!storeAsProperties; | |
| return this; | |
| } | |
| /** | |
| * Retrieve option value. | |
| * | |
| * @param {string} key | |
| * @return {object} value | |
| */ | |
| getOptionValue(key) { | |
| if (this._storeOptionsAsProperties) { | |
| return this[key]; | |
| } | |
| return this._optionValues[key]; | |
| } | |
| /** | |
| * Store option value. | |
| * | |
| * @param {string} key | |
| * @param {object} value | |
| * @return {Command} `this` command for chaining | |
| */ | |
| setOptionValue(key, value) { | |
| return this.setOptionValueWithSource(key, value, void 0); | |
| } | |
| /** | |
| * Store option value and where the value came from. | |
| * | |
| * @param {string} key | |
| * @param {object} value | |
| * @param {string} source - expected values are default/config/env/cli/implied | |
| * @return {Command} `this` command for chaining | |
| */ | |
| setOptionValueWithSource(key, value, source) { | |
| if (this._storeOptionsAsProperties) { | |
| this[key] = value; | |
| } else { | |
| this._optionValues[key] = value; | |
| } | |
| this._optionValueSources[key] = source; | |
| return this; | |
| } | |
| /** | |
| * Get source of option value. | |
| * Expected values are default | config | env | cli | implied | |
| * | |
| * @param {string} key | |
| * @return {string} | |
| */ | |
| getOptionValueSource(key) { | |
| return this._optionValueSources[key]; | |
| } | |
| /** | |
| * Get source of option value. See also .optsWithGlobals(). | |
| * Expected values are default | config | env | cli | implied | |
| * | |
| * @param {string} key | |
| * @return {string} | |
| */ | |
| getOptionValueSourceWithGlobals(key) { | |
| let source; | |
| this._getCommandAndAncestors().forEach((cmd) => { | |
| if (cmd.getOptionValueSource(key) !== void 0) { | |
| source = cmd.getOptionValueSource(key); | |
| } | |
| }); | |
| return source; | |
| } | |
| /** | |
| * Get user arguments from implied or explicit arguments. | |
| * Side-effects: set _scriptPath if args included script. Used for default program name, and subcommand searches. | |
| * | |
| * @private | |
| */ | |
| _prepareUserArgs(argv, parseOptions) { | |
| if (argv !== void 0 && !Array.isArray(argv)) { | |
| throw new Error("first parameter to parse must be array or undefined"); | |
| } | |
| parseOptions = parseOptions || {}; | |
| if (argv === void 0 && parseOptions.from === void 0) { | |
| if (process2.versions?.electron) { | |
| parseOptions.from = "electron"; | |
| } | |
| const execArgv = process2.execArgv ?? []; | |
| if (execArgv.includes("-e") || execArgv.includes("--eval") || execArgv.includes("-p") || execArgv.includes("--print")) { | |
| parseOptions.from = "eval"; | |
| } | |
| } | |
| if (argv === void 0) { | |
| argv = process2.argv; | |
| } | |
| this.rawArgs = argv.slice(); | |
| let userArgs; | |
| switch (parseOptions.from) { | |
| case void 0: | |
| case "node": | |
| this._scriptPath = argv[1]; | |
| userArgs = argv.slice(2); | |
| break; | |
| case "electron": | |
| if (process2.defaultApp) { | |
| this._scriptPath = argv[1]; | |
| userArgs = argv.slice(2); | |
| } else { | |
| userArgs = argv.slice(1); | |
| } | |
| break; | |
| case "user": | |
| userArgs = argv.slice(0); | |
| break; | |
| case "eval": | |
| userArgs = argv.slice(1); | |
| break; | |
| default: | |
| throw new Error( | |
| `unexpected parse option { from: '${parseOptions.from}' }` | |
| ); | |
| } | |
| if (!this._name && this._scriptPath) | |
| this.nameFromFilename(this._scriptPath); | |
| this._name = this._name || "program"; | |
| return userArgs; | |
| } | |
| /** | |
| * Parse `argv`, setting options and invoking commands when defined. | |
| * | |
| * Use parseAsync instead of parse if any of your action handlers are async. | |
| * | |
| * Call with no parameters to parse `process.argv`. Detects Electron and special node options like `node --eval`. Easy mode! | |
| * | |
| * Or call with an array of strings to parse, and optionally where the user arguments start by specifying where the arguments are `from`: | |
| * - `'node'`: default, `argv[0]` is the application and `argv[1]` is the script being run, with user arguments after that | |
| * - `'electron'`: `argv[0]` is the application and `argv[1]` varies depending on whether the electron application is packaged | |
| * - `'user'`: just user arguments | |
| * | |
| * @example | |
| * program.parse(); // parse process.argv and auto-detect electron and special node flags | |
| * program.parse(process.argv); // assume argv[0] is app and argv[1] is script | |
| * program.parse(my-args, { from: 'user' }); // just user supplied arguments, nothing special about argv[0] | |
| * | |
| * @param {string[]} [argv] - optional, defaults to process.argv | |
| * @param {object} [parseOptions] - optionally specify style of options with from: node/user/electron | |
| * @param {string} [parseOptions.from] - where the args are from: 'node', 'user', 'electron' | |
| * @return {Command} `this` command for chaining | |
| */ | |
| parse(argv, parseOptions) { | |
| const userArgs = this._prepareUserArgs(argv, parseOptions); | |
| this._parseCommand([], userArgs); | |
| return this; | |
| } | |
| /** | |
| * Parse `argv`, setting options and invoking commands when defined. | |
| * | |
| * Call with no parameters to parse `process.argv`. Detects Electron and special node options like `node --eval`. Easy mode! | |
| * | |
| * Or call with an array of strings to parse, and optionally where the user arguments start by specifying where the arguments are `from`: | |
| * - `'node'`: default, `argv[0]` is the application and `argv[1]` is the script being run, with user arguments after that | |
| * - `'electron'`: `argv[0]` is the application and `argv[1]` varies depending on whether the electron application is packaged | |
| * - `'user'`: just user arguments | |
| * | |
| * @example | |
| * await program.parseAsync(); // parse process.argv and auto-detect electron and special node flags | |
| * await program.parseAsync(process.argv); // assume argv[0] is app and argv[1] is script | |
| * await program.parseAsync(my-args, { from: 'user' }); // just user supplied arguments, nothing special about argv[0] | |
| * | |
| * @param {string[]} [argv] | |
| * @param {object} [parseOptions] | |
| * @param {string} parseOptions.from - where the args are from: 'node', 'user', 'electron' | |
| * @return {Promise} | |
| */ | |
| async parseAsync(argv, parseOptions) { | |
| const userArgs = this._prepareUserArgs(argv, parseOptions); | |
| await this._parseCommand([], userArgs); | |
| return this; | |
| } | |
| /** | |
| * Execute a sub-command executable. | |
| * | |
| * @private | |
| */ | |
| _executeSubCommand(subcommand, args) { | |
| args = args.slice(); | |
| let launchWithNode = false; | |
| const sourceExt = [".js", ".ts", ".tsx", ".mjs", ".cjs"]; | |
| function findFile(baseDir, baseName) { | |
| const localBin = path.resolve(baseDir, baseName); | |
| if (fs.existsSync(localBin)) return localBin; | |
| if (sourceExt.includes(path.extname(baseName))) return void 0; | |
| const foundExt = sourceExt.find( | |
| (ext) => fs.existsSync(`${localBin}${ext}`) | |
| ); | |
| if (foundExt) return `${localBin}${foundExt}`; | |
| return void 0; | |
| } | |
| this._checkForMissingMandatoryOptions(); | |
| this._checkForConflictingOptions(); | |
| let executableFile = subcommand._executableFile || `${this._name}-${subcommand._name}`; | |
| let executableDir = this._executableDir || ""; | |
| if (this._scriptPath) { | |
| let resolvedScriptPath; | |
| try { | |
| resolvedScriptPath = fs.realpathSync(this._scriptPath); | |
| } catch (err) { | |
| resolvedScriptPath = this._scriptPath; | |
| } | |
| executableDir = path.resolve( | |
| path.dirname(resolvedScriptPath), | |
| executableDir | |
| ); | |
| } | |
| if (executableDir) { | |
| let localFile = findFile(executableDir, executableFile); | |
| if (!localFile && !subcommand._executableFile && this._scriptPath) { | |
| const legacyName = path.basename( | |
| this._scriptPath, | |
| path.extname(this._scriptPath) | |
| ); | |
| if (legacyName !== this._name) { | |
| localFile = findFile( | |
| executableDir, | |
| `${legacyName}-${subcommand._name}` | |
| ); | |
| } | |
| } | |
| executableFile = localFile || executableFile; | |
| } | |
| launchWithNode = sourceExt.includes(path.extname(executableFile)); | |
| let proc; | |
| if (process2.platform !== "win32") { | |
| if (launchWithNode) { | |
| args.unshift(executableFile); | |
| args = incrementNodeInspectorPort(process2.execArgv).concat(args); | |
| proc = childProcess.spawn(process2.argv[0], args, { stdio: "inherit" }); | |
| } else { | |
| proc = childProcess.spawn(executableFile, args, { stdio: "inherit" }); | |
| } | |
| } else { | |
| args.unshift(executableFile); | |
| args = incrementNodeInspectorPort(process2.execArgv).concat(args); | |
| proc = childProcess.spawn(process2.execPath, args, { stdio: "inherit" }); | |
| } | |
| if (!proc.killed) { | |
| const signals = ["SIGUSR1", "SIGUSR2", "SIGTERM", "SIGINT", "SIGHUP"]; | |
| signals.forEach((signal) => { | |
| process2.on(signal, () => { | |
| if (proc.killed === false && proc.exitCode === null) { | |
| proc.kill(signal); | |
| } | |
| }); | |
| }); | |
| } | |
| const exitCallback = this._exitCallback; | |
| proc.on("close", (code) => { | |
| code = code ?? 1; | |
| if (!exitCallback) { | |
| process2.exit(code); | |
| } else { | |
| exitCallback( | |
| new CommanderError2( | |
| code, | |
| "commander.executeSubCommandAsync", | |
| "(close)" | |
| ) | |
| ); | |
| } | |
| }); | |
| proc.on("error", (err) => { | |
| if (err.code === "ENOENT") { | |
| const executableDirMessage = executableDir ? `searched for local subcommand relative to directory '${executableDir}'` : "no directory for search for local subcommand, use .executableDir() to supply a custom directory"; | |
| const executableMissing = `'${executableFile}' does not exist | |
| - if '${subcommand._name}' is not meant to be an executable command, remove description parameter from '.command()' and use '.description()' instead | |
| - if the default executable name is not suitable, use the executableFile option to supply a custom name or path | |
| - ${executableDirMessage}`; | |
| throw new Error(executableMissing); | |
| } else if (err.code === "EACCES") { | |
| throw new Error(`'${executableFile}' not executable`); | |
| } | |
| if (!exitCallback) { | |
| process2.exit(1); | |
| } else { | |
| const wrappedError = new CommanderError2( | |
| 1, | |
| "commander.executeSubCommandAsync", | |
| "(error)" | |
| ); | |
| wrappedError.nestedError = err; | |
| exitCallback(wrappedError); | |
| } | |
| }); | |
| this.runningCommand = proc; | |
| } | |
| /** | |
| * @private | |
| */ | |
| _dispatchSubcommand(commandName, operands, unknown) { | |
| const subCommand = this._findCommand(commandName); | |
| if (!subCommand) this.help({ error: true }); | |
| let promiseChain; | |
| promiseChain = this._chainOrCallSubCommandHook( | |
| promiseChain, | |
| subCommand, | |
| "preSubcommand" | |
| ); | |
| promiseChain = this._chainOrCall(promiseChain, () => { | |
| if (subCommand._executableHandler) { | |
| this._executeSubCommand(subCommand, operands.concat(unknown)); | |
| } else { | |
| return subCommand._parseCommand(operands, unknown); | |
| } | |
| }); | |
| return promiseChain; | |
| } | |
| /** | |
| * Invoke help directly if possible, or dispatch if necessary. | |
| * e.g. help foo | |
| * | |
| * @private | |
| */ | |
| _dispatchHelpCommand(subcommandName) { | |
| if (!subcommandName) { | |
| this.help(); | |
| } | |
| const subCommand = this._findCommand(subcommandName); | |
| if (subCommand && !subCommand._executableHandler) { | |
| subCommand.help(); | |
| } | |
| return this._dispatchSubcommand( | |
| subcommandName, | |
| [], | |
| [this._getHelpOption()?.long ?? this._getHelpOption()?.short ?? "--help"] | |
| ); | |
| } | |
| /** | |
| * Check this.args against expected this.registeredArguments. | |
| * | |
| * @private | |
| */ | |
| _checkNumberOfArguments() { | |
| this.registeredArguments.forEach((arg, i) => { | |
| if (arg.required && this.args[i] == null) { | |
| this.missingArgument(arg.name()); | |
| } | |
| }); | |
| if (this.registeredArguments.length > 0 && this.registeredArguments[this.registeredArguments.length - 1].variadic) { | |
| return; | |
| } | |
| if (this.args.length > this.registeredArguments.length) { | |
| this._excessArguments(this.args); | |
| } | |
| } | |
| /** | |
| * Process this.args using this.registeredArguments and save as this.processedArgs! | |
| * | |
| * @private | |
| */ | |
| _processArguments() { | |
| const myParseArg = (argument, value, previous) => { | |
| let parsedValue = value; | |
| if (value !== null && argument.parseArg) { | |
| const invalidValueMessage = `error: command-argument value '${value}' is invalid for argument '${argument.name()}'.`; | |
| parsedValue = this._callParseArg( | |
| argument, | |
| value, | |
| previous, | |
| invalidValueMessage | |
| ); | |
| } | |
| return parsedValue; | |
| }; | |
| this._checkNumberOfArguments(); | |
| const processedArgs = []; | |
| this.registeredArguments.forEach((declaredArg, index) => { | |
| let value = declaredArg.defaultValue; | |
| if (declaredArg.variadic) { | |
| if (index < this.args.length) { | |
| value = this.args.slice(index); | |
| if (declaredArg.parseArg) { | |
| value = value.reduce((processed, v) => { | |
| return myParseArg(declaredArg, v, processed); | |
| }, declaredArg.defaultValue); | |
| } | |
| } else if (value === void 0) { | |
| value = []; | |
| } | |
| } else if (index < this.args.length) { | |
| value = this.args[index]; | |
| if (declaredArg.parseArg) { | |
| value = myParseArg(declaredArg, value, declaredArg.defaultValue); | |
| } | |
| } | |
| processedArgs[index] = value; | |
| }); | |
| this.processedArgs = processedArgs; | |
| } | |
| /** | |
| * Once we have a promise we chain, but call synchronously until then. | |
| * | |
| * @param {(Promise|undefined)} promise | |
| * @param {Function} fn | |
| * @return {(Promise|undefined)} | |
| * @private | |
| */ | |
| _chainOrCall(promise, fn) { | |
| if (promise && promise.then && typeof promise.then === "function") { | |
| return promise.then(() => fn()); | |
| } | |
| return fn(); | |
| } | |
| /** | |
| * | |
| * @param {(Promise|undefined)} promise | |
| * @param {string} event | |
| * @return {(Promise|undefined)} | |
| * @private | |
| */ | |
| _chainOrCallHooks(promise, event) { | |
| let result = promise; | |
| const hooks = []; | |
| this._getCommandAndAncestors().reverse().filter((cmd) => cmd._lifeCycleHooks[event] !== void 0).forEach((hookedCommand) => { | |
| hookedCommand._lifeCycleHooks[event].forEach((callback) => { | |
| hooks.push({ hookedCommand, callback }); | |
| }); | |
| }); | |
| if (event === "postAction") { | |
| hooks.reverse(); | |
| } | |
| hooks.forEach((hookDetail) => { | |
| result = this._chainOrCall(result, () => { | |
| return hookDetail.callback(hookDetail.hookedCommand, this); | |
| }); | |
| }); | |
| return result; | |
| } | |
| /** | |
| * | |
| * @param {(Promise|undefined)} promise | |
| * @param {Command} subCommand | |
| * @param {string} event | |
| * @return {(Promise|undefined)} | |
| * @private | |
| */ | |
| _chainOrCallSubCommandHook(promise, subCommand, event) { | |
| let result = promise; | |
| if (this._lifeCycleHooks[event] !== void 0) { | |
| this._lifeCycleHooks[event].forEach((hook) => { | |
| result = this._chainOrCall(result, () => { | |
| return hook(this, subCommand); | |
| }); | |
| }); | |
| } | |
| return result; | |
| } | |
| /** | |
| * Process arguments in context of this command. | |
| * Returns action result, in case it is a promise. | |
| * | |
| * @private | |
| */ | |
| _parseCommand(operands, unknown) { | |
| const parsed = this.parseOptions(unknown); | |
| this._parseOptionsEnv(); | |
| this._parseOptionsImplied(); | |
| operands = operands.concat(parsed.operands); | |
| unknown = parsed.unknown; | |
| this.args = operands.concat(unknown); | |
| if (operands && this._findCommand(operands[0])) { | |
| return this._dispatchSubcommand(operands[0], operands.slice(1), unknown); | |
| } | |
| if (this._getHelpCommand() && operands[0] === this._getHelpCommand().name()) { | |
| return this._dispatchHelpCommand(operands[1]); | |
| } | |
| if (this._defaultCommandName) { | |
| this._outputHelpIfRequested(unknown); | |
| return this._dispatchSubcommand( | |
| this._defaultCommandName, | |
| operands, | |
| unknown | |
| ); | |
| } | |
| if (this.commands.length && this.args.length === 0 && !this._actionHandler && !this._defaultCommandName) { | |
| this.help({ error: true }); | |
| } | |
| this._outputHelpIfRequested(parsed.unknown); | |
| this._checkForMissingMandatoryOptions(); | |
| this._checkForConflictingOptions(); | |
| const checkForUnknownOptions = () => { | |
| if (parsed.unknown.length > 0) { | |
| this.unknownOption(parsed.unknown[0]); | |
| } | |
| }; | |
| const commandEvent = `command:${this.name()}`; | |
| if (this._actionHandler) { | |
| checkForUnknownOptions(); | |
| this._processArguments(); | |
| let promiseChain; | |
| promiseChain = this._chainOrCallHooks(promiseChain, "preAction"); | |
| promiseChain = this._chainOrCall( | |
| promiseChain, | |
| () => this._actionHandler(this.processedArgs) | |
| ); | |
| if (this.parent) { | |
| promiseChain = this._chainOrCall(promiseChain, () => { | |
| this.parent.emit(commandEvent, operands, unknown); | |
| }); | |
| } | |
| promiseChain = this._chainOrCallHooks(promiseChain, "postAction"); | |
| return promiseChain; | |
| } | |
| if (this.parent && this.parent.listenerCount(commandEvent)) { | |
| checkForUnknownOptions(); | |
| this._processArguments(); | |
| this.parent.emit(commandEvent, operands, unknown); | |
| } else if (operands.length) { | |
| if (this._findCommand("*")) { | |
| return this._dispatchSubcommand("*", operands, unknown); | |
| } | |
| if (this.listenerCount("command:*")) { | |
| this.emit("command:*", operands, unknown); | |
| } else if (this.commands.length) { | |
| this.unknownCommand(); | |
| } else { | |
| checkForUnknownOptions(); | |
| this._processArguments(); | |
| } | |
| } else if (this.commands.length) { | |
| checkForUnknownOptions(); | |
| this.help({ error: true }); | |
| } else { | |
| checkForUnknownOptions(); | |
| this._processArguments(); | |
| } | |
| } | |
| /** | |
| * Find matching command. | |
| * | |
| * @private | |
| * @return {Command | undefined} | |
| */ | |
| _findCommand(name) { | |
| if (!name) return void 0; | |
| return this.commands.find( | |
| (cmd) => cmd._name === name || cmd._aliases.includes(name) | |
| ); | |
| } | |
| /** | |
| * Return an option matching `arg` if any. | |
| * | |
| * @param {string} arg | |
| * @return {Option} | |
| * @package | |
| */ | |
| _findOption(arg) { | |
| return this.options.find((option) => option.is(arg)); | |
| } | |
| /** | |
| * Display an error message if a mandatory option does not have a value. | |
| * Called after checking for help flags in leaf subcommand. | |
| * | |
| * @private | |
| */ | |
| _checkForMissingMandatoryOptions() { | |
| this._getCommandAndAncestors().forEach((cmd) => { | |
| cmd.options.forEach((anOption) => { | |
| if (anOption.mandatory && cmd.getOptionValue(anOption.attributeName()) === void 0) { | |
| cmd.missingMandatoryOptionValue(anOption); | |
| } | |
| }); | |
| }); | |
| } | |
| /** | |
| * Display an error message if conflicting options are used together in this. | |
| * | |
| * @private | |
| */ | |
| _checkForConflictingLocalOptions() { | |
| const definedNonDefaultOptions = this.options.filter((option) => { | |
| const optionKey = option.attributeName(); | |
| if (this.getOptionValue(optionKey) === void 0) { | |
| return false; | |
| } | |
| return this.getOptionValueSource(optionKey) !== "default"; | |
| }); | |
| const optionsWithConflicting = definedNonDefaultOptions.filter( | |
| (option) => option.conflictsWith.length > 0 | |
| ); | |
| optionsWithConflicting.forEach((option) => { | |
| const conflictingAndDefined = definedNonDefaultOptions.find( | |
| (defined) => option.conflictsWith.includes(defined.attributeName()) | |
| ); | |
| if (conflictingAndDefined) { | |
| this._conflictingOption(option, conflictingAndDefined); | |
| } | |
| }); | |
| } | |
| /** | |
| * Display an error message if conflicting options are used together. | |
| * Called after checking for help flags in leaf subcommand. | |
| * | |
| * @private | |
| */ | |
| _checkForConflictingOptions() { | |
| this._getCommandAndAncestors().forEach((cmd) => { | |
| cmd._checkForConflictingLocalOptions(); | |
| }); | |
| } | |
| /** | |
| * Parse options from `argv` removing known options, | |
| * and return argv split into operands and unknown arguments. | |
| * | |
| * Examples: | |
| * | |
| * argv => operands, unknown | |
| * --known kkk op => [op], [] | |
| * op --known kkk => [op], [] | |
| * sub --unknown uuu op => [sub], [--unknown uuu op] | |
| * sub -- --unknown uuu op => [sub --unknown uuu op], [] | |
| * | |
| * @param {string[]} argv | |
| * @return {{operands: string[], unknown: string[]}} | |
| */ | |
| parseOptions(argv) { | |
| const operands = []; | |
| const unknown = []; | |
| let dest = operands; | |
| const args = argv.slice(); | |
| function maybeOption(arg) { | |
| return arg.length > 1 && arg[0] === "-"; | |
| } | |
| let activeVariadicOption = null; | |
| while (args.length) { | |
| const arg = args.shift(); | |
| if (arg === "--") { | |
| if (dest === unknown) dest.push(arg); | |
| dest.push(...args); | |
| break; | |
| } | |
| if (activeVariadicOption && !maybeOption(arg)) { | |
| this.emit(`option:${activeVariadicOption.name()}`, arg); | |
| continue; | |
| } | |
| activeVariadicOption = null; | |
| if (maybeOption(arg)) { | |
| const option = this._findOption(arg); | |
| if (option) { | |
| if (option.required) { | |
| const value = args.shift(); | |
| if (value === void 0) this.optionMissingArgument(option); | |
| this.emit(`option:${option.name()}`, value); | |
| } else if (option.optional) { | |
| let value = null; | |
| if (args.length > 0 && !maybeOption(args[0])) { | |
| value = args.shift(); | |
| } | |
| this.emit(`option:${option.name()}`, value); | |
| } else { | |
| this.emit(`option:${option.name()}`); | |
| } | |
| activeVariadicOption = option.variadic ? option : null; | |
| continue; | |
| } | |
| } | |
| if (arg.length > 2 && arg[0] === "-" && arg[1] !== "-") { | |
| const option = this._findOption(`-${arg[1]}`); | |
| if (option) { | |
| if (option.required || option.optional && this._combineFlagAndOptionalValue) { | |
| this.emit(`option:${option.name()}`, arg.slice(2)); | |
| } else { | |
| this.emit(`option:${option.name()}`); | |
| args.unshift(`-${arg.slice(2)}`); | |
| } | |
| continue; | |
| } | |
| } | |
| if (/^--[^=]+=/.test(arg)) { | |
| const index = arg.indexOf("="); | |
| const option = this._findOption(arg.slice(0, index)); | |
| if (option && (option.required || option.optional)) { | |
| this.emit(`option:${option.name()}`, arg.slice(index + 1)); | |
| continue; | |
| } | |
| } | |
| if (maybeOption(arg)) { | |
| dest = unknown; | |
| } | |
| if ((this._enablePositionalOptions || this._passThroughOptions) && operands.length === 0 && unknown.length === 0) { | |
| if (this._findCommand(arg)) { | |
| operands.push(arg); | |
| if (args.length > 0) unknown.push(...args); | |
| break; | |
| } else if (this._getHelpCommand() && arg === this._getHelpCommand().name()) { | |
| operands.push(arg); | |
| if (args.length > 0) operands.push(...args); | |
| break; | |
| } else if (this._defaultCommandName) { | |
| unknown.push(arg); | |
| if (args.length > 0) unknown.push(...args); | |
| break; | |
| } | |
| } | |
| if (this._passThroughOptions) { | |
| dest.push(arg); | |
| if (args.length > 0) dest.push(...args); | |
| break; | |
| } | |
| dest.push(arg); | |
| } | |
| return { operands, unknown }; | |
| } | |
| /** | |
| * Return an object containing local option values as key-value pairs. | |
| * | |
| * @return {object} | |
| */ | |
| opts() { | |
| if (this._storeOptionsAsProperties) { | |
| const result = {}; | |
| const len = this.options.length; | |
| for (let i = 0; i < len; i++) { | |
| const key = this.options[i].attributeName(); | |
| result[key] = key === this._versionOptionName ? this._version : this[key]; | |
| } | |
| return result; | |
| } | |
| return this._optionValues; | |
| } | |
| /** | |
| * Return an object containing merged local and global option values as key-value pairs. | |
| * | |
| * @return {object} | |
| */ | |
| optsWithGlobals() { | |
| return this._getCommandAndAncestors().reduce( | |
| (combinedOptions, cmd) => Object.assign(combinedOptions, cmd.opts()), | |
| {} | |
| ); | |
| } | |
| /** | |
| * Display error message and exit (or call exitOverride). | |
| * | |
| * @param {string} message | |
| * @param {object} [errorOptions] | |
| * @param {string} [errorOptions.code] - an id string representing the error | |
| * @param {number} [errorOptions.exitCode] - used with process.exit | |
| */ | |
| error(message, errorOptions) { | |
| this._outputConfiguration.outputError( | |
| `${message} | |
| `, | |
| this._outputConfiguration.writeErr | |
| ); | |
| if (typeof this._showHelpAfterError === "string") { | |
| this._outputConfiguration.writeErr(`${this._showHelpAfterError} | |
| `); | |
| } else if (this._showHelpAfterError) { | |
| this._outputConfiguration.writeErr("\n"); | |
| this.outputHelp({ error: true }); | |
| } | |
| const config = errorOptions || {}; | |
| const exitCode = config.exitCode || 1; | |
| const code = config.code || "commander.error"; | |
| this._exit(exitCode, code, message); | |
| } | |
| /** | |
| * Apply any option related environment variables, if option does | |
| * not have a value from cli or client code. | |
| * | |
| * @private | |
| */ | |
| _parseOptionsEnv() { | |
| this.options.forEach((option) => { | |
| if (option.envVar && option.envVar in process2.env) { | |
| const optionKey = option.attributeName(); | |
| if (this.getOptionValue(optionKey) === void 0 || ["default", "config", "env"].includes( | |
| this.getOptionValueSource(optionKey) | |
| )) { | |
| if (option.required || option.optional) { | |
| this.emit(`optionEnv:${option.name()}`, process2.env[option.envVar]); | |
| } else { | |
| this.emit(`optionEnv:${option.name()}`); | |
| } | |
| } | |
| } | |
| }); | |
| } | |
| /** | |
| * Apply any implied option values, if option is undefined or default value. | |
| * | |
| * @private | |
| */ | |
| _parseOptionsImplied() { | |
| const dualHelper = new DualOptions(this.options); | |
| const hasCustomOptionValue = (optionKey) => { | |
| return this.getOptionValue(optionKey) !== void 0 && !["default", "implied"].includes(this.getOptionValueSource(optionKey)); | |
| }; | |
| this.options.filter( | |
| (option) => option.implied !== void 0 && hasCustomOptionValue(option.attributeName()) && dualHelper.valueFromOption( | |
| this.getOptionValue(option.attributeName()), | |
| option | |
| ) | |
| ).forEach((option) => { | |
| Object.keys(option.implied).filter((impliedKey) => !hasCustomOptionValue(impliedKey)).forEach((impliedKey) => { | |
| this.setOptionValueWithSource( | |
| impliedKey, | |
| option.implied[impliedKey], | |
| "implied" | |
| ); | |
| }); | |
| }); | |
| } | |
| /** | |
| * Argument `name` is missing. | |
| * | |
| * @param {string} name | |
| * @private | |
| */ | |
| missingArgument(name) { | |
| const message = `error: missing required argument '${name}'`; | |
| this.error(message, { code: "commander.missingArgument" }); | |
| } | |
| /** | |
| * `Option` is missing an argument. | |
| * | |
| * @param {Option} option | |
| * @private | |
| */ | |
| optionMissingArgument(option) { | |
| const message = `error: option '${option.flags}' argument missing`; | |
| this.error(message, { code: "commander.optionMissingArgument" }); | |
| } | |
| /** | |
| * `Option` does not have a value, and is a mandatory option. | |
| * | |
| * @param {Option} option | |
| * @private | |
| */ | |
| missingMandatoryOptionValue(option) { | |
| const message = `error: required option '${option.flags}' not specified`; | |
| this.error(message, { code: "commander.missingMandatoryOptionValue" }); | |
| } | |
| /** | |
| * `Option` conflicts with another option. | |
| * | |
| * @param {Option} option | |
| * @param {Option} conflictingOption | |
| * @private | |
| */ | |
| _conflictingOption(option, conflictingOption) { | |
| const findBestOptionFromValue = (option2) => { | |
| const optionKey = option2.attributeName(); | |
| const optionValue = this.getOptionValue(optionKey); | |
| const negativeOption = this.options.find( | |
| (target) => target.negate && optionKey === target.attributeName() | |
| ); | |
| const positiveOption = this.options.find( | |
| (target) => !target.negate && optionKey === target.attributeName() | |
| ); | |
| if (negativeOption && (negativeOption.presetArg === void 0 && optionValue === false || negativeOption.presetArg !== void 0 && optionValue === negativeOption.presetArg)) { | |
| return negativeOption; | |
| } | |
| return positiveOption || option2; | |
| }; | |
| const getErrorMessage = (option2) => { | |
| const bestOption = findBestOptionFromValue(option2); | |
| const optionKey = bestOption.attributeName(); | |
| const source = this.getOptionValueSource(optionKey); | |
| if (source === "env") { | |
| return `environment variable '${bestOption.envVar}'`; | |
| } | |
| return `option '${bestOption.flags}'`; | |
| }; | |
| const message = `error: ${getErrorMessage(option)} cannot be used with ${getErrorMessage(conflictingOption)}`; | |
| this.error(message, { code: "commander.conflictingOption" }); | |
| } | |
| /** | |
| * Unknown option `flag`. | |
| * | |
| * @param {string} flag | |
| * @private | |
| */ | |
| unknownOption(flag) { | |
| if (this._allowUnknownOption) return; | |
| let suggestion = ""; | |
| if (flag.startsWith("--") && this._showSuggestionAfterError) { | |
| let candidateFlags = []; | |
| let command = this; | |
| do { | |
| const moreFlags = command.createHelp().visibleOptions(command).filter((option) => option.long).map((option) => option.long); | |
| candidateFlags = candidateFlags.concat(moreFlags); | |
| command = command.parent; | |
| } while (command && !command._enablePositionalOptions); | |
| suggestion = suggestSimilar(flag, candidateFlags); | |
| } | |
| const message = `error: unknown option '${flag}'${suggestion}`; | |
| this.error(message, { code: "commander.unknownOption" }); | |
| } | |
| /** | |
| * Excess arguments, more than expected. | |
| * | |
| * @param {string[]} receivedArgs | |
| * @private | |
| */ | |
| _excessArguments(receivedArgs) { | |
| if (this._allowExcessArguments) return; | |
| const expected = this.registeredArguments.length; | |
| const s = expected === 1 ? "" : "s"; | |
| const forSubcommand = this.parent ? ` for '${this.name()}'` : ""; | |
| const message = `error: too many arguments${forSubcommand}. Expected ${expected} argument${s} but got ${receivedArgs.length}.`; | |
| this.error(message, { code: "commander.excessArguments" }); | |
| } | |
| /** | |
| * Unknown command. | |
| * | |
| * @private | |
| */ | |
| unknownCommand() { | |
| const unknownName = this.args[0]; | |
| let suggestion = ""; | |
| if (this._showSuggestionAfterError) { | |
| const candidateNames = []; | |
| this.createHelp().visibleCommands(this).forEach((command) => { | |
| candidateNames.push(command.name()); | |
| if (command.alias()) candidateNames.push(command.alias()); | |
| }); | |
| suggestion = suggestSimilar(unknownName, candidateNames); | |
| } | |
| const message = `error: unknown command '${unknownName}'${suggestion}`; | |
| this.error(message, { code: "commander.unknownCommand" }); | |
| } | |
| /** | |
| * Get or set the program version. | |
| * | |
| * This method auto-registers the "-V, --version" option which will print the version number. | |
| * | |
| * You can optionally supply the flags and description to override the defaults. | |
| * | |
| * @param {string} [str] | |
| * @param {string} [flags] | |
| * @param {string} [description] | |
| * @return {(this | string | undefined)} `this` command for chaining, or version string if no arguments | |
| */ | |
| version(str, flags, description) { | |
| if (str === void 0) return this._version; | |
| this._version = str; | |
| flags = flags || "-V, --version"; | |
| description = description || "output the version number"; | |
| const versionOption = this.createOption(flags, description); | |
| this._versionOptionName = versionOption.attributeName(); | |
| this._registerOption(versionOption); | |
| this.on("option:" + versionOption.name(), () => { | |
| this._outputConfiguration.writeOut(`${str} | |
| `); | |
| this._exit(0, "commander.version", str); | |
| }); | |
| return this; | |
| } | |
| /** | |
| * Set the description. | |
| * | |
| * @param {string} [str] | |
| * @param {object} [argsDescription] | |
| * @return {(string|Command)} | |
| */ | |
| description(str, argsDescription) { | |
| if (str === void 0 && argsDescription === void 0) | |
| return this._description; | |
| this._description = str; | |
| if (argsDescription) { | |
| this._argsDescription = argsDescription; | |
| } | |
| return this; | |
| } | |
| /** | |
| * Set the summary. Used when listed as subcommand of parent. | |
| * | |
| * @param {string} [str] | |
| * @return {(string|Command)} | |
| */ | |
| summary(str) { | |
| if (str === void 0) return this._summary; | |
| this._summary = str; | |
| return this; | |
| } | |
| /** | |
| * Set an alias for the command. | |
| * | |
| * You may call more than once to add multiple aliases. Only the first alias is shown in the auto-generated help. | |
| * | |
| * @param {string} [alias] | |
| * @return {(string|Command)} | |
| */ | |
| alias(alias) { | |
| if (alias === void 0) return this._aliases[0]; | |
| let command = this; | |
| if (this.commands.length !== 0 && this.commands[this.commands.length - 1]._executableHandler) { | |
| command = this.commands[this.commands.length - 1]; | |
| } | |
| if (alias === command._name) | |
| throw new Error("Command alias can't be the same as its name"); | |
| const matchingCommand = this.parent?._findCommand(alias); | |
| if (matchingCommand) { | |
| const existingCmd = [matchingCommand.name()].concat(matchingCommand.aliases()).join("|"); | |
| throw new Error( | |
| `cannot add alias '${alias}' to command '${this.name()}' as already have command '${existingCmd}'` | |
| ); | |
| } | |
| command._aliases.push(alias); | |
| return this; | |
| } | |
| /** | |
| * Set aliases for the command. | |
| * | |
| * Only the first alias is shown in the auto-generated help. | |
| * | |
| * @param {string[]} [aliases] | |
| * @return {(string[]|Command)} | |
| */ | |
| aliases(aliases) { | |
| if (aliases === void 0) return this._aliases; | |
| aliases.forEach((alias) => this.alias(alias)); | |
| return this; | |
| } | |
| /** | |
| * Set / get the command usage `str`. | |
| * | |
| * @param {string} [str] | |
| * @return {(string|Command)} | |
| */ | |
| usage(str) { | |
| if (str === void 0) { | |
| if (this._usage) return this._usage; | |
| const args = this.registeredArguments.map((arg) => { | |
| return humanReadableArgName(arg); | |
| }); | |
| return [].concat( | |
| this.options.length || this._helpOption !== null ? "[options]" : [], | |
| this.commands.length ? "[command]" : [], | |
| this.registeredArguments.length ? args : [] | |
| ).join(" "); | |
| } | |
| this._usage = str; | |
| return this; | |
| } | |
| /** | |
| * Get or set the name of the command. | |
| * | |
| * @param {string} [str] | |
| * @return {(string|Command)} | |
| */ | |
| name(str) { | |
| if (str === void 0) return this._name; | |
| this._name = str; | |
| return this; | |
| } | |
| /** | |
| * Set the name of the command from script filename, such as process.argv[1], | |
| * or require.main.filename, or __filename. | |
| * | |
| * (Used internally and public although not documented in README.) | |
| * | |
| * @example | |
| * program.nameFromFilename(require.main.filename); | |
| * | |
| * @param {string} filename | |
| * @return {Command} | |
| */ | |
| nameFromFilename(filename) { | |
| this._name = path.basename(filename, path.extname(filename)); | |
| return this; | |
| } | |
| /** | |
| * Get or set the directory for searching for executable subcommands of this command. | |
| * | |
| * @example | |
| * program.executableDir(__dirname); | |
| * // or | |
| * program.executableDir('subcommands'); | |
| * | |
| * @param {string} [path] | |
| * @return {(string|null|Command)} | |
| */ | |
| executableDir(path2) { | |
| if (path2 === void 0) return this._executableDir; | |
| this._executableDir = path2; | |
| return this; | |
| } | |
| /** | |
| * Return program help documentation. | |
| * | |
| * @param {{ error: boolean }} [contextOptions] - pass {error:true} to wrap for stderr instead of stdout | |
| * @return {string} | |
| */ | |
| helpInformation(contextOptions) { | |
| const helper = this.createHelp(); | |
| if (helper.helpWidth === void 0) { | |
| helper.helpWidth = contextOptions && contextOptions.error ? this._outputConfiguration.getErrHelpWidth() : this._outputConfiguration.getOutHelpWidth(); | |
| } | |
| return helper.formatHelp(this, helper); | |
| } | |
| /** | |
| * @private | |
| */ | |
| _getHelpContext(contextOptions) { | |
| contextOptions = contextOptions || {}; | |
| const context = { error: !!contextOptions.error }; | |
| let write; | |
| if (context.error) { | |
| write = (arg) => this._outputConfiguration.writeErr(arg); | |
| } else { | |
| write = (arg) => this._outputConfiguration.writeOut(arg); | |
| } | |
| context.write = contextOptions.write || write; | |
| context.command = this; | |
| return context; | |
| } | |
| /** | |
| * Output help information for this command. | |
| * | |
| * Outputs built-in help, and custom text added using `.addHelpText()`. | |
| * | |
| * @param {{ error: boolean } | Function} [contextOptions] - pass {error:true} to write to stderr instead of stdout | |
| */ | |
| outputHelp(contextOptions) { | |
| let deprecatedCallback; | |
| if (typeof contextOptions === "function") { | |
| deprecatedCallback = contextOptions; | |
| contextOptions = void 0; | |
| } | |
| const context = this._getHelpContext(contextOptions); | |
| this._getCommandAndAncestors().reverse().forEach((command) => command.emit("beforeAllHelp", context)); | |
| this.emit("beforeHelp", context); | |
| let helpInformation = this.helpInformation(context); | |
| if (deprecatedCallback) { | |
| helpInformation = deprecatedCallback(helpInformation); | |
| if (typeof helpInformation !== "string" && !Buffer.isBuffer(helpInformation)) { | |
| throw new Error("outputHelp callback must return a string or a Buffer"); | |
| } | |
| } | |
| context.write(helpInformation); | |
| if (this._getHelpOption()?.long) { | |
| this.emit(this._getHelpOption().long); | |
| } | |
| this.emit("afterHelp", context); | |
| this._getCommandAndAncestors().forEach( | |
| (command) => command.emit("afterAllHelp", context) | |
| ); | |
| } | |
| /** | |
| * You can pass in flags and a description to customise the built-in help option. | |
| * Pass in false to disable the built-in help option. | |
| * | |
| * @example | |
| * program.helpOption('-?, --help' 'show help'); // customise | |
| * program.helpOption(false); // disable | |
| * | |
| * @param {(string | boolean)} flags | |
| * @param {string} [description] | |
| * @return {Command} `this` command for chaining | |
| */ | |
| helpOption(flags, description) { | |
| if (typeof flags === "boolean") { | |
| if (flags) { | |
| this._helpOption = this._helpOption ?? void 0; | |
| } else { | |
| this._helpOption = null; | |
| } | |
| return this; | |
| } | |
| flags = flags ?? "-h, --help"; | |
| description = description ?? "display help for command"; | |
| this._helpOption = this.createOption(flags, description); | |
| return this; | |
| } | |
| /** | |
| * Lazy create help option. | |
| * Returns null if has been disabled with .helpOption(false). | |
| * | |
| * @returns {(Option | null)} the help option | |
| * @package | |
| */ | |
| _getHelpOption() { | |
| if (this._helpOption === void 0) { | |
| this.helpOption(void 0, void 0); | |
| } | |
| return this._helpOption; | |
| } | |
| /** | |
| * Supply your own option to use for the built-in help option. | |
| * This is an alternative to using helpOption() to customise the flags and description etc. | |
| * | |
| * @param {Option} option | |
| * @return {Command} `this` command for chaining | |
| */ | |
| addHelpOption(option) { | |
| this._helpOption = option; | |
| return this; | |
| } | |
| /** | |
| * Output help information and exit. | |
| * | |
| * Outputs built-in help, and custom text added using `.addHelpText()`. | |
| * | |
| * @param {{ error: boolean }} [contextOptions] - pass {error:true} to write to stderr instead of stdout | |
| */ | |
| help(contextOptions) { | |
| this.outputHelp(contextOptions); | |
| let exitCode = process2.exitCode || 0; | |
| if (exitCode === 0 && contextOptions && typeof contextOptions !== "function" && contextOptions.error) { | |
| exitCode = 1; | |
| } | |
| this._exit(exitCode, "commander.help", "(outputHelp)"); | |
| } | |
| /** | |
| * Add additional text to be displayed with the built-in help. | |
| * | |
| * Position is 'before' or 'after' to affect just this command, | |
| * and 'beforeAll' or 'afterAll' to affect this command and all its subcommands. | |
| * | |
| * @param {string} position - before or after built-in help | |
| * @param {(string | Function)} text - string to add, or a function returning a string | |
| * @return {Command} `this` command for chaining | |
| */ | |
| addHelpText(position, text) { | |
| const allowedValues = ["beforeAll", "before", "after", "afterAll"]; | |
| if (!allowedValues.includes(position)) { | |
| throw new Error(`Unexpected value for position to addHelpText. | |
| Expecting one of '${allowedValues.join("', '")}'`); | |
| } | |
| const helpEvent = `${position}Help`; | |
| this.on(helpEvent, (context) => { | |
| let helpStr; | |
| if (typeof text === "function") { | |
| helpStr = text({ error: context.error, command: context.command }); | |
| } else { | |
| helpStr = text; | |
| } | |
| if (helpStr) { | |
| context.write(`${helpStr} | |
| `); | |
| } | |
| }); | |
| return this; | |
| } | |
| /** | |
| * Output help information if help flags specified | |
| * | |
| * @param {Array} args - array of options to search for help flags | |
| * @private | |
| */ | |
| _outputHelpIfRequested(args) { | |
| const helpOption = this._getHelpOption(); | |
| const helpRequested = helpOption && args.find((arg) => helpOption.is(arg)); | |
| if (helpRequested) { | |
| this.outputHelp(); | |
| this._exit(0, "commander.helpDisplayed", "(outputHelp)"); | |
| } | |
| } | |
| }; | |
| function incrementNodeInspectorPort(args) { | |
| return args.map((arg) => { | |
| if (!arg.startsWith("--inspect")) { | |
| return arg; | |
| } | |
| let debugOption; | |
| let debugHost = "127.0.0.1"; | |
| let debugPort = "9229"; | |
| let match; | |
| if ((match = arg.match(/^(--inspect(-brk)?)$/)) !== null) { | |
| debugOption = match[1]; | |
| } else if ((match = arg.match(/^(--inspect(-brk|-port)?)=([^:]+)$/)) !== null) { | |
| debugOption = match[1]; | |
| if (/^\d+$/.test(match[3])) { | |
| debugPort = match[3]; | |
| } else { | |
| debugHost = match[3]; | |
| } | |
| } else if ((match = arg.match(/^(--inspect(-brk|-port)?)=([^:]+):(\d+)$/)) !== null) { | |
| debugOption = match[1]; | |
| debugHost = match[3]; | |
| debugPort = match[4]; | |
| } | |
| if (debugOption && debugPort !== "0") { | |
| return `${debugOption}=${debugHost}:${parseInt(debugPort) + 1}`; | |
| } | |
| return arg; | |
| }); | |
| } | |
| exports2.Command = Command2; | |
| } | |
| }); | |
| // node_modules/.pnpm/commander@12.1.0/node_modules/commander/index.js | |
| var require_commander = __commonJS({ | |
| "node_modules/.pnpm/commander@12.1.0/node_modules/commander/index.js"(exports2) { | |
| var { Argument: Argument2 } = require_argument(); | |
| var { Command: Command2 } = require_command(); | |
| var { CommanderError: CommanderError2, InvalidArgumentError: InvalidArgumentError2 } = require_error(); | |
| var { Help: Help2 } = require_help(); | |
| var { Option: Option2 } = require_option(); | |
| exports2.program = new Command2(); | |
| exports2.createCommand = (name) => new Command2(name); | |
| exports2.createOption = (flags, description) => new Option2(flags, description); | |
| exports2.createArgument = (name, description) => new Argument2(name, description); | |
| exports2.Command = Command2; | |
| exports2.Option = Option2; | |
| exports2.Argument = Argument2; | |
| exports2.Help = Help2; | |
| exports2.CommanderError = CommanderError2; | |
| exports2.InvalidArgumentError = InvalidArgumentError2; | |
| exports2.InvalidOptionArgumentError = InvalidArgumentError2; | |
| } | |
| }); | |
| // node_modules/.pnpm/commander@12.1.0/node_modules/commander/esm.mjs | |
| var import_index = __toESM(require_commander(), 1); | |
| var { | |
| program, | |
| createCommand, | |
| createArgument, | |
| createOption, | |
| CommanderError, | |
| InvalidArgumentError, | |
| InvalidOptionArgumentError, | |
| // deprecated old name | |
| Command, | |
| Argument, | |
| Option, | |
| Help | |
| } = import_index.default; | |
| // src/lib/exec.ts | |
| var import_node_child_process = require("node:child_process"); | |
| var import_node_fs = require("node:fs"); | |
| var import_node_path = require("node:path"); | |
| // src/lib/log.ts | |
| var isTTY = process.stdout.isTTY === true; | |
| var COLORS = { | |
| red: "\x1B[0;31m", | |
| redBold: "\x1B[1;31m", | |
| green: "\x1B[0;32m", | |
| yellow: "\x1B[0;33m", | |
| blue: "\x1B[0;34m", | |
| dim: "\x1B[2m", | |
| reset: "\x1B[0m" | |
| }; | |
| function paint(color, text) { | |
| if (!isTTY) return text; | |
| return `${COLORS[color]}${text}${COLORS.reset}`; | |
| } | |
| function sanitize(s) { | |
| return s.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, ""); | |
| } | |
| function info(msg) { | |
| console.log(`${paint("blue", "[INFO]")} ${sanitize(msg)}`); | |
| } | |
| function ok(msg) { | |
| console.log(`${paint("green", "[ OK ]")} ${sanitize(msg)}`); | |
| } | |
| function warn(msg) { | |
| console.log(`${paint("yellow", "[C\u1EA2NH B\xC1O]")} ${sanitize(msg)}`); | |
| } | |
| function danger(msg) { | |
| console.log(`${paint("redBold", "[NGUY HI\u1EC2M]")} ${paint("red", sanitize(msg))}`); | |
| } | |
| function colorText(color, text) { | |
| return paint(color, text); | |
| } | |
| function step(msg) { | |
| console.log(`${paint("dim", " ->")} ${sanitize(msg)}`); | |
| } | |
| var NappError = class extends Error { | |
| }; | |
| function die(msg) { | |
| throw new NappError(msg); | |
| } | |
| function printDie(msg) { | |
| console.error(`${paint("red", "[L\u1ED6I]")} ${sanitize(msg)}`); | |
| } | |
| function dryRunNotice(msg) { | |
| console.log(`${paint("yellow", "[DRY-RUN]")} ${sanitize(msg)}`); | |
| } | |
| function section(title) { | |
| console.log(); | |
| console.log(paint("green", `=== ${title} ===`)); | |
| } | |
| // src/lib/exec.ts | |
| var state = { dryRun: false, verbose: false }; | |
| function setDryRun(v) { | |
| state.dryRun = v; | |
| } | |
| function execCapture(cmd, args = []) { | |
| const opts = { | |
| encoding: "utf8", | |
| maxBuffer: 1024 * 1024 * 32 | |
| }; | |
| const res = (0, import_node_child_process.spawnSync)(cmd, args, opts); | |
| if (res.error) { | |
| return { code: 127, stdout: "", stderr: String(res.error.message) }; | |
| } | |
| return { | |
| code: res.status ?? 1, | |
| stdout: res.stdout ?? "", | |
| stderr: res.stderr ?? "" | |
| }; | |
| } | |
| function commandExists(cmd) { | |
| const res = execCapture("bash", ["-lc", `command -v ${shQuote(cmd)}`]); | |
| return res.code === 0 && res.stdout.trim().length > 0; | |
| } | |
| function shQuote(s) { | |
| return `'${s.replace(/'/g, `'\\''`)}'`; | |
| } | |
| function runCmd(cmd, args = [], opts = {}) { | |
| const display = [cmd, ...args].join(" "); | |
| if (state.dryRun) { | |
| dryRunNotice(display); | |
| return { code: 0, stdout: "", stderr: "" }; | |
| } | |
| if (state.verbose) dryRunNotice(`+ ${display}`); | |
| const res = (0, import_node_child_process.spawnSync)(cmd, args, { | |
| encoding: "utf8", | |
| stdio: opts.input !== void 0 ? ["pipe", "pipe", "pipe"] : "inherit", | |
| input: opts.input, | |
| maxBuffer: 1024 * 1024 * 64 | |
| }); | |
| if (res.error) { | |
| if (opts.silentFail) return { code: 127, stdout: "", stderr: String(res.error.message) }; | |
| die(`Kh\xF4ng th\u1EC3 ch\u1EA1y l\u1EC7nh '${cmd}': ${res.error.message}`); | |
| } | |
| const code = res.status ?? 1; | |
| if (code !== 0 && !opts.silentFail) { | |
| die(`L\u1EC7nh th\u1EA5t b\u1EA1i (m\xE3 ${code}): ${display}`); | |
| } | |
| return { code, stdout: res.stdout ?? "", stderr: res.stderr ?? "" }; | |
| } | |
| function runAs(user, cmd, args = [], opts = {}) { | |
| const display = `(ch\u1EA1y b\u1EB1ng user ${user}${opts.cwd ? `, cwd ${opts.cwd}` : ""}) ${[cmd, ...args].join(" ")}`; | |
| if (state.dryRun) { | |
| dryRunNotice(display); | |
| return { code: 0, stdout: "", stderr: "" }; | |
| } | |
| if (state.verbose) dryRunNotice(`+ ${display}`); | |
| const sudoArgs = ["-u", user, "-H", "env"]; | |
| if (opts.env) { | |
| for (const [k, v] of Object.entries(opts.env)) sudoArgs.push(`${k}=${v}`); | |
| } | |
| sudoArgs.push(cmd, ...args); | |
| const res = (0, import_node_child_process.spawnSync)("sudo", sudoArgs, { encoding: "utf8", stdio: "inherit", cwd: opts.cwd ?? "/" }); | |
| if (res.error) { | |
| if (opts.silentFail) return { code: 127, stdout: "", stderr: String(res.error.message) }; | |
| const hint = /ENOENT/.test(String(res.error.message)) ? " ('sudo' ch\u01B0a \u0111\u01B0\u1EE3c c\xE0i tr\xEAn m\xE1y n\xE0y \u2014 ch\u1EA1y 'napp check --fix' tr\u01B0\u1EDBc.)" : ""; | |
| die(`Kh\xF4ng th\u1EC3 ch\u1EA1y l\u1EC7nh v\u1EDBi user ${user}: ${res.error.message}${hint}`); | |
| } | |
| const code = res.status ?? 1; | |
| if (code !== 0 && !opts.silentFail) die(`L\u1EC7nh th\u1EA5t b\u1EA1i (m\xE3 ${code}) d\u01B0\u1EDBi user ${user}: ${display}`); | |
| return { code, stdout: res.stdout ?? "", stderr: res.stderr ?? "" }; | |
| } | |
| function execCaptureAs(user, cmd, args = [], opts = {}) { | |
| const sudoArgs = ["-n", "-u", user, "-H", "env"]; | |
| if (opts.env) { | |
| for (const [k, v] of Object.entries(opts.env)) sudoArgs.push(`${k}=${v}`); | |
| } | |
| sudoArgs.push(cmd, ...args); | |
| const res = (0, import_node_child_process.spawnSync)("sudo", sudoArgs, { | |
| encoding: "utf8", | |
| cwd: opts.cwd ?? "/", | |
| timeout: opts.timeoutMs, | |
| maxBuffer: 1024 * 1024 * 64 | |
| }); | |
| if (res.error) { | |
| return { code: 127, stdout: res.stdout ?? "", stderr: String(res.error.message) }; | |
| } | |
| return { code: res.status ?? 1, stdout: res.stdout ?? "", stderr: res.stderr ?? "" }; | |
| } | |
| function writeFile(path, content, mode = 420) { | |
| if (state.dryRun) { | |
| dryRunNotice(`S\u1EBD ghi file ${path} (${content.length} bytes, mode ${mode.toString(8)})`); | |
| if (state.verbose) { | |
| console.log( | |
| content.split("\n").map((l) => ` | ${l}`).join("\n") | |
| ); | |
| } | |
| return; | |
| } | |
| (0, import_node_fs.mkdirSync)((0, import_node_path.dirname)(path), { recursive: true }); | |
| (0, import_node_fs.writeFileSync)(path, content, { encoding: "utf8" }); | |
| (0, import_node_fs.chmodSync)(path, mode); | |
| } | |
| function appendFile(path, content) { | |
| if (state.dryRun) { | |
| dryRunNotice(`S\u1EBD n\u1ED1i th\xEAm v\xE0o file ${path} (${content.length} bytes)`); | |
| return; | |
| } | |
| (0, import_node_fs.appendFileSync)(path, content, { encoding: "utf8" }); | |
| } | |
| function ensureDir(path, mode = 493) { | |
| if (state.dryRun) { | |
| if (!(0, import_node_fs.existsSync)(path)) dryRunNotice(`S\u1EBD t\u1EA1o th\u01B0 m\u1EE5c ${path}`); | |
| return; | |
| } | |
| (0, import_node_fs.mkdirSync)(path, { recursive: true, mode }); | |
| } | |
| function requireRoot() { | |
| if (process.getuid && process.getuid() !== 0) { | |
| die("L\u1EC7nh n\xE0y c\u1EA7n quy\u1EC1n root. H\xE3y ch\u1EA1y l\u1EA1i v\u1EDBi sudo."); | |
| } | |
| } | |
| function isServiceActive(name) { | |
| return execCapture("systemctl", ["is-active", "--quiet", name]).code === 0; | |
| } | |
| // src/version.ts | |
| var NAPP_VERSION = true ? "1.26.0" : "0.0.0-dev"; | |
| var NAPP_UPDATE_URL_DEFAULT = "https://gist.githubusercontent.com/anhtuank7c/ef7ac27df205d70cf1f789bb420ec013/raw/napp.cjs"; | |
| var CHANGELOG = `# Changelog | |
| ## 1.26.0 | |
| - M\u1EDAI 'napp mem': ph\xE1t hi\u1EC7n r\xF2 r\u1EC9 b\u1ED9 nh\u1EDB TR\u01AF\u1EDAC khi app ch\u1EBFt, v\xE0 ch\u1EE5p heap \u0111\u1EC3 t\xECm | |
| th\u1EE7 ph\u1EA1m. Kh\xF4ng s\u1EEDa m\u1ED9t d\xF2ng code n\xE0o c\u1EE7a app. | |
| - T\xCDN HI\u1EC6U \u0110\xC3 N\u1EB0M S\u1EB4N \u1EDE \u0110\xD3 T\u1EEA \u0110\u1EA6U: m\u1ECDi unit napp \u0111\u1EC1u c\xF3 'Restart=always', n\xEAn app | |
| r\xF2 r\u1EC9 ch\u1EA1m tr\u1EA7n heap s\u1EBD CH\u1EBET r\u1ED3i \u0111\u01B0\u1EE3c systemd L\u1EB6NG L\u1EBC kh\u1EDFi \u0111\u1ED9ng l\u1EA1i \u2014 l\u1EB7p \u0111i | |
| l\u1EB7p l\u1EA1i nhi\u1EC1u ng\xE0y m\xE0 kh\xF4ng ai hay. systemd \u0111\xE3 \u0111\u1EBFm s\u1EB5n s\u1ED1 l\u1EA7n \u0111\xF3 ('NRestarts'), | |
| ch\u1EC9 l\xE0 ch\u01B0a ai \u0111\u1ECDc ra. 'napp mem status' v\xE0 'napp check' nay \u0111\u1ECDc ra. | |
| - 'napp mem watch': systemd timer l\u1EA5y m\u1EABu b\u1ED9 nh\u1EDB \u0111\u1ECBnh k\u1EF3 (m\u1EB7c \u0111\u1ECBnh 15 ph\xFAt) -> | |
| 'napp mem trend' k\u1EBFt lu\u1EADn xu h\u01B0\u1EDBng. | |
| - \u0110O 'anon' TRONG memory.stat, KH\xD4NG \u0111o memory.current: memory.current g\u1ED3m c\u1EA3 | |
| page cache \u2014 th\u1EE9 ph\xECnh ra co l\u1EA1i theo I/O c\u1EE7a c\u1EA3 m\xE1y v\xE0 \u0111\u1EE7 nhi\u1EC5u \u0111\u1EC3 d\xECm ch\u1EBFt | |
| t\xEDn hi\u1EC7u th\u1EADt. 'anon' l\xE0 heap/stack, \u0111\xFAng th\u1EE9 r\xF2 r\u1EC9 l\xE0m ph\xECnh. | |
| - BA QUY T\u1EAEC \u0110\u1EC2 KH\xD4NG K\xCAU OAN (k\xEAu oan v\xE0i l\u1EA7n l\xE0 ng\u01B0\u1EDDi d\xF9ng h\u1ECDc c\xE1ch ph\u1EDBt l\u1EDD): | |
| (1) ch\u1EC9 x\xE9t \u0111o\u1EA1n t\u1EEB l\u1EA7n restart g\u1EA7n nh\u1EA5t \u2014 m\u1ED7i l\u1EA7n kh\u1EDFi \u0111\u1ED9ng l\u1EA1i l\xE0 b\u1ED9 nh\u1EDB v\u1EC1 | |
| mo, gh\xE9p hai b\xEAn c\u1EE7a m\u1ED9t l\u1EA7n restart cho ra d\u1ED1c \xE2m v\xF4 ngh\u0129a che m\u1EA5t ch\xEDnh c\xE1i | |
| r\xF2 r\u1EC9 \u0111\xE3 g\xE2y ra n\xF3; (2) so TRUNG V\u1ECA hai ph\u1EA7n t\u01B0 \u0111\u1EA7u/cu\u1ED1i ch\u1EE9 kh\xF4ng so m\u1EABu \u0111\u1EA7u | |
| v\u1EDBi m\u1EABu cu\u1ED1i, v\xEC m\u1ED9t m\u1EABu r\u01A1i \u0111\xFAng l\xFAc GC l\u1EC7ch h\xE0ng ch\u1EE5c MB; (3) d\u01B0\u1EDBi 6 gi\u1EDD d\u1EEF | |
| li\u1EC7u th\xEC KH\xD4NG k\u1EBFt lu\u1EADn g\xEC \u2014 RSS c\u1EE7a Node lu\xF4n t\u0103ng l\xFAc \u0111\u1EA7u r\u1ED3i \u0111i ngang v\xEC V8 | |
| kh\xF4ng tr\u1EA3 b\u1ED9 nh\u1EDB v\u1EC1 OS s\u1EDBm. | |
| - 'napp mem guard <app>': th\xEAm '--heapsnapshot-near-heap-limit=1' v\xE0 | |
| '--heapsnapshot-signal=SIGUSR2' v\xE0o NODE_OPTIONS. C\u1EDD \u0111\u1EA7u khi\u1EBFn Node T\u1EF0 CH\u1EE4P | |
| heap ngay tr\u01B0\u1EDBc khi ch\u1EA1m tr\u1EA7n, thay v\xEC ch\u1EBFt m\xE0 kh\xF4ng \u0111\u1EC3 l\u1EA1i g\xEC. | |
| - 'napp mem snapshot <app>': ch\u1EE5p heap c\u1EE7a ti\u1EBFn tr\xECnh \u0110ANG CH\u1EA0Y, app v\u1EABn s\u1ED1ng. | |
| Ch\u1EDD t\u1EDBi khi file NG\u1EEANG T\u0102NG k\xEDch th\u01B0\u1EDBc r\u1ED3i m\u1EDBi b\xE1o xong \u2014 kh\xF4ng c\xF3 b\u01B0\u1EDBc \u0111\xF3 th\xEC | |
| r\u1EA5t d\u1EC5 \u0111em \u0111i ph\xE2n t\xEDch m\u1ED9t file m\u1EDBi ghi \u0111\u01B0\u1EE3c m\u1ED9t n\u1EEDa. | |
| - AN TO\xC0N: SIGUSR2 GI\u1EBET ti\u1EBFn tr\xECnh Node n\u1EBFu c\u1EDD ch\u01B0a c\xF3 hi\u1EC7u l\u1EF1c (\u0111\xF3 l\xE0 h\xE0nh vi | |
| m\u1EB7c \u0111\u1ECBnh c\u1EE7a t\xEDn hi\u1EC7u). N\xEAn napp \u0111\u1ECDc /proc/<pid>/environ \u0111\u1EC3 x\xE1c nh\u1EADn c\u1EDD TH\u1EACT S\u1EF0 | |
| \u0111ang ch\u1EA1y r\u1ED3i m\u1EDBi d\xE1m g\u1EEDi, v\xE0 T\u1EEA CH\u1ED0I n\u1EBFu kh\xF4ng ch\u1EAFc. \u0110\u1ECDc m\xF4i tr\u01B0\u1EDDng th\u1EADt ch\u1EE9 | |
| kh\xF4ng \u0111\u1ECDc file unit, v\xEC '.env' c\u1EE7a app ghi \u0111\xE8 \u0111\u01B0\u1EE3c NODE_OPTIONS. | |
| - S\u1EECA L\u1ED6I N\u1EB6NG: 'Environment=NODE_OPTIONS=...' KH\xD4NG \u0111\u01B0\u1EE3c b\u1ECDc nh\xE1y k\xE9p. systemd | |
| t\xE1ch directive n\xE0y theo D\u1EA4U C\xC1CH, n\xEAn nhi\u1EC1u c\u1EDD s\u1EBD b\u1ECB hi\u1EC3u th\xE0nh nhi\u1EC1u ph\xE9p g\xE1n | |
| v\xE0 M\u1ECCI C\u1EDC SAU C\u1EDC \u0110\u1EA6U TI\xCAN b\u1ECB v\u1EE9t \u0111i \u2014 b\u1EB1ng ch\u1EE9ng duy nh\u1EA5t l\xE0 m\u1ED9t d\xF2ng 'Invalid | |
| environment assignment, ignoring' trong journal m\xE0 kh\xF4ng ai \u0111\u1ECDc. Tr\u01B0\u1EDBc b\u1EA3n n\xE0y | |
| ch\u1EC9 c\xF3 \u0111\xFAng m\u1ED9t c\u1EDD n\xEAn l\u1ED7i ch\u01B0a l\u1ED9; th\xEAm c\u1EDD th\u1EE9 hai l\xE0 l\u1ED9 ngay. \u0110\xE3 ki\u1EC3m ch\u1EE9ng | |
| tr\xEAn systemd th\u1EADt: tr\u01B0\u1EDBc khi s\u1EEDa ti\u1EBFn tr\xECnh ch\u1EC9 nh\u1EADn '--max-old-space-size', | |
| sau khi s\u1EEDa nh\u1EADn \u0111\u1EE7 c\u1EA3 ba c\u1EDD. | |
| - S\u1ED0 \u0110O TH\u1EACT v\u1EC1 chi ph\xED ch\u1EE5p heap (\u0111\u1EEBng ch\u1EE5p app web v\xE0o gi\u1EDD cao \u0111i\u1EC3m): file l\u1EDBn | |
| kho\u1EA3ng G\u1EA4P \u0110\xD4I heap v\xE0 m\u1EA5t V\xC0I PH\xDAT \u0111\u1EC3 ghi \u2014 heap 96MB -> file 184MB, 176 gi\xE2y; | |
| heap 128MB -> 237MB. Node LU\xD4N ghi v\xE0o th\u01B0 m\u1EE5c l\xE0m vi\u1EC7c c\u1EE7a app, kh\xF4ng \u0111\u1ED5i \u0111\u01B0\u1EE3c | |
| ch\u1ED7. D\u1EEBng/restart \u0111\u01A1n v\u1ECB gi\u1EEFa ch\u1EEBng cho ra file C\u1EE4T (\u0111\xE3 ki\u1EC3m ch\u1EE9ng: 0 byte). | |
| - 'napp check' b\xE1o th\xEAm ba th\u1EE9: \u0111\u01A1n v\u1ECB b\u1ECB systemd kh\u1EDFi \u0111\u1ED9ng l\u1EA1i, \u0111\u01A1n v\u1ECB c\xF3 b\u1ED9 nh\u1EDB | |
| t\u0103ng li\xEAn t\u1EE5c, v\xE0 file .heapsnapshot c\xF2n s\xF3t trong th\u01B0 m\u1EE5c app (b\u1EB1ng ch\u1EE9ng app | |
| \u0111\xE3 ch\u1EA1m tr\u1EA7n heap). | |
| - C\u1ED0 \xDD KH\xD4NG bi\u1EBFn napp th\xE0nh APM: c\u1EA7n quan s\xE1t th\u1EADt s\u1EF1 th\xEC prom-client + | |
| Prometheus/Grafana m\u1EDBi \u0111\xFAng c\xF4ng c\u1EE5. V\xE0 tuy\u1EC7t \u0111\u1ED1i kh\xF4ng d\xF9ng '--inspect' tr\xEAn | |
| production \u2014 n\xF3 m\u1EDF c\u1ED5ng debugger, ra t\u1EDBi Internet l\xE0 t\u01B0\u01A1ng \u0111\u01B0\u01A1ng RCE. | |
| ## 1.25.0 | |
| - M\u1EDAI: WEB APP \u0110\u01AF\u1EE2C \u01AFU TI\xCAN H\u01A0N BACKGROUND SERVICE. Tr\u01B0\u1EDBc \u0111\xE2y napp \u0111\u1ED1i x\u1EED v\u1EDBi hai | |
| lo\u1EA1i n\xE0y ho\xE0n to\xE0n nh\u01B0 nhau \u2014 c\xF9ng ph\u1EA7n heap, v\xE0 KH\xD4NG c\xF3 \u01B0u ti\xEAn CPU n\xE0o c\u1EA3. | |
| Ngh\u0129a l\xE0 m\u1ED9t worker cron ch\u1EA1y m\u1ED7i gi\u1EDD \u0111\u01B0\u1EE3c \u0111\xFAng b\u1EB1ng heap c\u1EE7a web app \u0111ang ph\u1EE5c | |
| v\u1EE5 traffic, v\xE0 m\u1ED9t worker n\xE9n \u1EA3nh tranh CPU ngang c\u01A1 v\u1EDBi n\xF3. | |
| - Heap V8 nay chia theo TR\u1ECCNG S\u1ED0: m\u1EABu s\u1ED1 l\xE0 's\u1ED1 app + s\u1ED1 service * 0.5' thay v\xEC | |
| t\u1ED5ng s\u1ED1 \u0111\u01A1n v\u1ECB. T\u1ED5ng RAM c\u1EA5p ph\xE1t KH\xD4NG \u0111\u1ED5i, ch\u1EC9 ph\xE2n b\u1ED5 l\u1EA1i v\u1EC1 ph\xEDa traffic. | |
| V\xED d\u1EE5 m\xE1y 4GB, 2 app + 2 service: tr\u01B0\u1EDBc c\u1EA3 b\u1ED1n \u0111\u01B0\u1EE3c 327MB; nay web 436MB, | |
| service 218MB (t\u1ED5ng v\u1EABn 1308MB). \u0110\u1ED5i t\u1EF7 l\u1EC7 b\u1EB1ng 'tune apply --service-weight' | |
| (0.1-1; 1 = chia \u0111\u1EC1u nh\u01B0 tr\u01B0\u1EDBc). | |
| - HEAP L\xC0 L\u1EDAP Y\u1EBEU NH\u1EA4T, \u0111\u1EEBng tr\xF4ng ch\u1EDD v\xE0o n\xF3. '--max-old-space-size' l\xE0 m\u1ED9t | |
| TR\u1EA6N ch\u1EE9 kh\xF4ng ph\u1EA3i RAM \u0111\u1EB7t tr\u01B0\u1EDBc: cho web app heap l\u1EDBn h\u01A1n KH\xD4NG l\u1EA5y \u0111i g\xEC c\u1EE7a | |
| worker, n\xF3 ch\u1EC9 cho web app l\u1EDBn th\xEAm tr\u01B0\u1EDBc khi thrash GC ho\u1EB7c ch\u1EBFt. | |
| - M\u1EDAI 'CPUWeight' (web 200 / service 50) v\xE0 'IOWeight' trong unit systemd \u2014 \u0110\xC2Y | |
| m\u1EDBi l\xE0 l\u1EDBp ng\u01B0\u1EDDi d\xF9ng th\u1EADt s\u1EF1 c\u1EA3m nh\u1EADn \u0111\u01B0\u1EE3c. M\u1ED9t worker sharp/ffmpeg chi\u1EBFm h\u1EBFt | |
| l\xF5i l\xE0m m\u1ECDi request ch\u1EADm h\u1EB3n, v\xE0 kh\xF4ng con s\u1ED1 heap n\xE0o \u0111\u1ED5i \u0111\u01B0\u1EE3c \u0111i\u1EC1u \u0111\xF3. | |
| CPUWeight l\xE0 t\u1EF7 l\u1EC7 chia CH\u1EC8 \xE1p d\u1EE5ng KHI C\xD3 TRANH CH\u1EA4P: worker r\u1EA3nh th\xEC web app | |
| v\u1EABn d\xF9ng 100% CPU nh\u01B0 th\u01B0\u1EDDng. \u0110o tr\xEAn m\xE1y th\u1EADt, hai ti\u1EBFn tr\xECnh c\xF9ng \u0111\u1ED1t CPU | |
| 100% tr\xEAn m\u1ED9t l\xF5i 12 gi\xE2y: web 9597ms, worker 2401ms \u2014 \u0111\xFAng 4.00 : 1. | |
| - M\u1EDAI 'MemoryHigh' cho background service (3x heap, s\xE0n 256MB): gi\u1EDBi h\u1EA1n M\u1EC0M \u2014 | |
| v\u01B0\u1EE3t ng\u01B0\u1EE1ng th\xEC kernel throttle v\xE0 thu h\u1ED3i b\u1ED9 nh\u1EDB c\u1EE7a ri\xEAng worker \u0111\xF3, KH\xD4NG | |
| gi\u1EBFt ti\u1EBFn tr\xECnh. C\u1ED0 \xDD KH\xD4NG d\xF9ng MemoryMax (gi\u1EDBi h\u1EA1n c\u1EE9ng, v\u01B0\u1EE3t l\xE0 OOM-kill): | |
| bi\u1EBFn m\u1ED9t worker ch\u1EADm th\xE0nh m\u1ED9t worker CH\u1EBET th\xEC t\u1EC7 h\u01A1n v\u1EA5n \u0111\u1EC1 ban \u0111\u1EA7u. Web app | |
| KH\xD4NG b\u1ECB \u0111\u1EB7t MemoryHigh. | |
| - \xC1P \u0110\u01AF\u1EE2C CHO UNIT T\u1EA0O B\u1EB0NG B\u1EA2N NAPP C\u0168: CPUWeight/IOWeight \u0111\u01B0\u1EE3c v\xE1 v\xE0o unit hi\u1EC7n | |
| c\xF3 b\u1EB1ng m\u1ED9t ph\xE9p PH\u1EAAU THU\u1EACT ri\xEAng (patchUnitPriority) \u2014 kh\xF4ng render l\u1EA1i unit, | |
| kh\xF4ng \u0111\u1EE5ng ExecStart/User/Group. Kh\xF4ng c\xF3 b\u01B0\u1EDBc n\xE0y th\xEC directive m\u1EDBi ch\u1EC9 t\u1EDBi | |
| \u0111\u01B0\u1EE3c unit c\u0169 qua '--sync-units', th\u1EE9 g\u1EA7n nh\u01B0 kh\xF4ng ai ch\u1EA1y: l\u1EC7nh b\xE1o th\xE0nh | |
| c\xF4ng, 'tune show' in ra t\u1EF7 l\u1EC7 \u01B0u ti\xEAn, m\xE0 unit th\u1EADt th\xEC tr\u1ED1ng kh\xF4ng. | |
| \u01AFu ti\xEAn CPU/IO \xE1p NGAY b\u1EB1ng daemon-reload, KH\xD4NG c\u1EA7n restart app (\u0111\xE3 ki\u1EC3m | |
| ch\u1EE9ng: cpu.weight trong kernel \u0111\u1ED5i 200 -> 350 v\u1EDBi c\xF9ng PID). Ch\u1EC9 heap m\u1EDBi b\u1EAFt | |
| bu\u1ED9c restart v\xEC NODE_OPTIONS ch\u1EC9 \u0111\u01B0\u1EE3c \u0111\u1ECDc l\xFAc ti\u1EBFn tr\xECnh kh\u1EDFi \u0111\u1ED9ng. | |
| - \u0110\u1EEANG TIN 'systemctl show -p CPUWeight': n\xF3 ch\u1EC9 \u0111\u1ECDc l\u1EA1i gi\xE1 tr\u1ECB \u0111\xE3 C\u1EA4U H\xCCNH | |
| trong unit, k\u1EC3 c\u1EA3 khi cgroup controller 'cpu' kh\xF4ng b\u1EADt v\xE0 d\xF2ng \u0111\xF3 ho\xE0n to\xE0n v\xF4 | |
| hi\u1EC7u \u2014 \u0111o \u0111\u01B0\u1EE3c tr\u01B0\u1EDDng h\u1EE3p systemctl tr\u1EA3 200 trong khi hai ti\u1EBFn tr\xECnh v\u1EABn chia | |
| CPU 1:1. 'tune apply' nay \u0111\u1ED1i chi\u1EBFu v\u1EDBi 'cpu.weight' TH\u1EACT trong cgroup v\xE0 b\xE1o | |
| c\xE1o k\u1EBFt qu\u1EA3 th\u1EADt. | |
| - TRUNG TH\u1EF0C V\u1EC0 TH\u1EE8 KH\xD4NG CH\u1EA0Y: IOWeight ch\u1EC9 hi\u1EC7u l\u1EF1c v\u1EDBi I/O scheduler 'bfq' | |
| (VPS NVMe th\u01B0\u1EDDng d\xF9ng 'none'/'mq-deadline' -> kernel kh\xF4ng t\u1EA1o c\u1EA3 file | |
| io.weight), v\xE0 MemoryHigh ch\u1EC9 t\u1ED3n t\u1EA1i \u1EDF cgroup v2 (Ubuntu 22.04+). 'tune show' | |
| d\xF2 v\xE0 n\xF3i th\u1EB3ng m\xE1y b\u1EA1n thu\u1ED9c nh\xF3m n\xE0o; tr\xEAn cgroup v1 napp B\u1ECE H\u1EB2N d\xF2ng | |
| MemoryHigh thay v\xEC ghi ra m\u1ED9t directive kernel s\u1EBD l\u1EDD \u0111i. | |
| - '--service-weight' \u0111\u01B0\u1EE3c L\u01AFU v\xE0o registry ch\u1EE9 kh\xF4ng ch\u1EC9 l\xE0 c\u1EDD c\u1EE7a m\u1ED9t l\u1EA7n ch\u1EA1y: | |
| kh\xF4ng nh\u1EDB th\xEC l\u1EA7n 'app create' k\u1EBF ti\u1EBFp s\u1EBD t\xEDnh l\u1EA1i theo m\u1EB7c \u0111\u1ECBnh v\xE0 \xE2m th\u1EA7m l\u1EADt | |
| ng\u01B0\u1EE3c l\u1EF1a ch\u1ECDn c\u1EE7a ng\u01B0\u1EDDi d\xF9ng. | |
| - S\u1EECA L\u1ED6I: 'app create' t\xEDnh heap ch\u1EC9 theo S\u1ED0 APP WEB, b\u1ECF qua background service \u2014 | |
| n\xEAn app \u0111\u1EA7u ti\xEAn tr\xEAn m\u1ED9t m\xE1y \u0111\xE3 c\xF3 s\u1EB5n worker nh\u1EADn heap qu\xE1 l\u1EDBn (v\xE0 ch\u1EC9 \u0111\u01B0\u1EE3c | |
| s\u1EEDa l\u1EA1i n\u1EBFu v\u1EC1 sau c\xF3 app th\u1EE9 hai). Nay m\u1EABu s\u1ED1 lu\xF4n t\xEDnh c\u1EA3 hai lo\u1EA1i. | |
| - 'napp check' b\xE1o th\xEAm: unit n\xE0o c\xF2n thi\u1EBFu CPUWeight/IOWeight. '--fix' \xE1p \u0111\u01B0\u1EE3c | |
| m\xE0 KH\xD4NG c\u1EA7n restart app. | |
| - CPUWeight/IOWeight n\u1EB1m trong danh s\xE1ch '# napp-preserve:' \u2014 \u0111\u1EB7t tay gi\xE1 tr\u1ECB | |
| ri\xEAng cho m\u1ED9t worker c\u1EE5 th\u1EC3 th\xEC napp kh\xF4ng ghi \u0111\xE8. | |
| ## 1.24.0 | |
| - M\u1EDAI: 'napp nginx scanblock' \u2014 ch\u1EB7n qu\xE9t l\u1ED7 h\u1ED5ng CMS/framework PHP \u1EDF t\u1EA7ng nginx. | |
| Request d\xF2 '/wp-login.php', '/wp-admin/', '/phpmyadmin/', '/cgi-bin/' v\xE0 m\u1ECDi | |
| \u0111u\xF4i .php/.asp/.jsp b\u1ECB tr\u1EA3 444 ngay, KH\xD4NG v\xF2ng qua Node. G\u1EE1 b\u1EB1ng | |
| 'napp nginx unscanblock'; t\u1EAFt ri\xEAng m\u1ED9t site: 'napp app set <domain> | |
| --no-scan-block'. | |
| - \u0110\u1EEANG K\u1EF2 V\u1ECCNG SAI V\xC0O CON S\u1ED0: 444 KH\xD4NG ti\u1EBFt ki\u1EC7m nhi\u1EC1u CPU nh\u01B0 t\xEAn g\u1ECDi g\u1EE3i \xFD \u2014 | |
| ph\u1EA7n \u0111\u1EAFt nh\u1EA5t c\u1EE7a m\u1ED9t request qu\xE9t l\xE0 b\u1EAFt tay TCP + TLS, m\xE0 nginx \u0111\xE3 tr\u1EA3 xong | |
| kho\u1EA3n \u0111\xF3 TR\u01AF\u1EDAC khi nh\xECn th\u1EA5y URI. Th\u1EE9 ti\u1EBFt ki\u1EC7m \u0111\u01B0\u1EE3c l\xE0 v\xF2ng qua Node. Kho\u1EA3n | |
| l\u1EDDi th\u1EADt: access log c\u1EE7a site s\u1EA1ch tr\u1EDF l\u1EA1i, v\xE0 fail2ban c\xF3 t\xEDn hi\u1EC7u ban g\u1EA7n nh\u01B0 | |
| ho\xE0n h\u1EA3o (ban \u1EDF t\u01B0\u1EDDng l\u1EEDa m\u1EDBi l\xE0 ch\u1ED7 b\u1ECF \u0111\u01B0\u1EE3c c\u1EA3 b\u1EAFt tay). | |
| - V\xEC l\xFD do tr\xEAn, request b\u1ECB ch\u1EB7n ghi sang FILE LOG RI\xCANG | |
| '/var/log/nginx/napp-scanner.log' ch\u1EE9 KH\xD4NG d\xF9ng 'access_log off'. T\u1EAFt log l\xE0 | |
| jail 'nginx-botsearch' (\u0111\u1ECDc /var/log/nginx/*access.log) m\u1EA5t lu\xF4n t\xEDn hi\u1EC7u: log | |
| s\u1EA1ch nh\u01B0ng scanner kh\xF4ng bao gi\u1EDD b\u1ECB ban. | |
| - M\u1EDAI: jail fail2ban 'napp-scanner' \u0111\u1ECDc file log ri\xEAng \u0111\xF3 \u2014 m\u1ECDi d\xF2ng trong n\xF3 | |
| ch\u1EAFc ch\u1EAFn l\xE0 scanner n\xEAn ban r\u1EA5t ch\u1EB7t (3 l\u1EA7n / 10 ph\xFAt -> c\u1EA5m 1 ng\xE0y) m\xE0 kh\xF4ng | |
| c\xF3 r\u1EE7i ro ban nh\u1EA7m. | |
| - S\u1EECA L\u1ED6I: c\xE1c jail nginx c\u1EE7a fail2ban tr\u01B0\u1EDBc \u0111\xE2y KH\xD4NG \u0111\u1ECDc \u0111\u01B0\u1EE3c g\xEC. '[DEFAULT]' | |
| \u0111\u1EB7t 'backend = systemd' (\u0111\xFAng cho sshd), nh\u01B0ng backend \u0111\xF3 khi\u1EBFn fail2ban B\u1ECE QUA | |
| 'logpath' v\xE0 \u0111i \u0111\u1ECDc journal \u2014 trong khi nginx ghi access log ra FILE. Jail v\u1EABn | |
| 'enabled', 'fail2ban-client status' v\u1EABn xanh, s\u1ED1 IP b\u1ECB ban \u0111\u1EE9ng y\xEAn \u1EDF 0 m\xE3i | |
| m\xE3i, kh\xF4ng c\xF3 l\u1ED7i n\xE0o \u0111\u1EC3 l\u1EA7n. Nay c\xE1c jail nginx ghi \u0111\xE8 'backend = auto'. | |
| - \xC1P \u0110\u01AF\u1EE2C CHO APP T\u1EA0O B\u1EB0NG B\u1EA2N NAPP C\u0168: vhost t\u1EA1o tr\u01B0\u1EDBc 1.19.0 kh\xF4ng c\xF3 d\xF2ng | |
| 'include' file location n\xE0o, n\xEAn m\u1ECDi th\u1EE9 napp ghi v\xE0o /etc/nginx/napp-locations/ | |
| \u0111\u1EC1u kh\xF4ng t\u1EDBi \u0111\u01B0\u1EE3c ch\xFAng \u2014 k\u1EC3 c\u1EA3 ch\u1EB7n qu\xE9t. 'napp nginx sync' v\xE0 | |
| 'napp nginx scanblock' nay t\u1EF1 ch\xE8n d\xF2ng include c\xF2n thi\u1EBFu, b\u1EB1ng ph\xE9p c\u1EAFt chu\u1ED7i | |
| theo kh\u1ED1i server (KH\xD4NG render l\u1EA1i vhost, n\xEAn kh\u1ED1i SSL c\u1EE7a certbot gi\u1EEF nguy\xEAn). | |
| - 'napp check' b\xE1o th\xEAm hai th\u1EE9: vhost n\xE0o c\xF2n thi\u1EBFu d\xF2ng include (asset t\u0129nh, | |
| upload, hotlink, ch\u1EB7n qu\xE9t \u0111\u1EC1u "\u0111\xE3 c\u1EA5u h\xECnh" m\xE0 kh\xF4ng h\u1EC1 ch\u1EA1y \u2014 nginx -t v\u1EABn | |
| xanh), v\xE0 ch\u1EB7n qu\xE9t l\u1ED7 h\u1ED5ng ch\u01B0a t\u1EEBng \u0111\u01B0\u1EE3c b\u1EADt. | |
| - 'napp nginx sync' nay ho\xE0n t\xE1c THEO GIAO D\u1ECACH: m\u1ED9t l\u1EC7nh ch\u1EA1m t\u1EDBi b\u1ED1n lo\u1EA1i file | |
| nh\xE2n v\u1EDBi s\u1ED1 app, v\xE0 ho\xE0n t\xE1c n\u1EEDa v\u1EDDi \u1EDF \u0111\xE2y kh\xF4ng ph\u1EA3i "m\u1EA5t c\u1EA5u h\xECnh" m\xE0 l\xE0 | |
| nginx KH\xD4NG N\u1EA0P \u0110\u01AF\u1EE2C (vhost c\xF3 d\xF2ng include c\xF2n file \u0111\u01B0\u1EE3c include th\xEC v\u1EEBa b\u1ECB | |
| xo\xE1) \u2014 t\u1EE9c l\xE0 T\u1EAET m\u1ECDi site tr\xEAn m\xE1y. | |
| - CH\xDA \xDD: 'napp nginx sync' nay render l\u1EA1i file location '<domain>.conf' t\u1EEB | |
| registry (tr\u01B0\u1EDBc \u0111\xE2y ch\u1EC9 v\xE1 vhost). S\u1EEDa tay file \u0111\xF3 s\u1EBD b\u1ECB ghi \u0111\xE8 \u2014 c\xF3 c\u1EA3nh b\xE1o | |
| v\xE0 sao l\u01B0u '.napp-orphaned'. Ch\u1ED7 \u0111\xFAng \u0111\u1EC3 \u0111\u1EB7t location ri\xEAng v\u1EABn l\xE0 file | |
| sidecar '<domain>.custom.conf'. | |
| - Menu t\u01B0\u01A1ng t\xE1c: th\xEAm m\u1EE5c 13 (ch\u1EB7n qu\xE9t) v\xE0 14 (g\u1EE1 ch\u1EB7n) \u1EDF nh\xF3m H\u1EA1 t\u1EA7ng \u2014 th\xEAm | |
| v\xE0o CU\u1ED0I \u0111\u1EC3 kh\xF4ng \u0111\xE1nh s\u1ED1 l\u1EA1i "Xem/\xC1p t\u1ED1i \u01B0u ph\u1EA7n c\u1EE9ng" (11, 12). | |
| ## 1.23.0 | |
| - S\u1EECA L\u1ED6I: file location T\u1EF0 SINH ghi \u0111\xE8 m\u1EA5t ph\u1EA7n ng\u01B0\u1EDDi d\xF9ng th\xEAm tay, KH\xD4NG c\u1EA3nh | |
| b\xE1o. '/etc/nginx/napp-locations/<domain>.conf' \u0111\u01B0\u1EE3c render l\u1EA1i TO\xC0N B\u1ED8 t\u1EEB | |
| registry \u1EDF BA ch\u1ED7 ('app create', 'app set', 'domain add/remove'), trong khi n\xF3 | |
| c\u0169ng l\xE0 ch\u1ED7 DUY NH\u1EA4T \u0111\u1EB7t \u0111\u01B0\u1EE3c location ri\xEAng \u2014 n\xEAn ai th\xEAm tay m\u1ED9t location | |
| (v\xED d\u1EE5 '/uploads/') \u0111\u1EC1u m\u1EA5t n\xF3 v\xE0o l\u1EA7n ch\u1EA1y k\u1EBF ti\u1EBFp c\u1EE7a b\u1EA5t k\u1EF3 l\u1EC7nh n\xE0o trong | |
| ba l\u1EC7nh \u0111\xF3. Tri\u1EC7u ch\u1EE9ng (\u1EA3nh v\u1EE1, 404) hi\u1EC7n ra r\u1EA5t l\xE2u sau, v\xE0o l\xFAc kh\xF4ng li\xEAn | |
| quan g\xEC t\u1EDBi l\u1EC7nh \u0111\xE3 g\xE2y ra. | |
| - M\u1EDAI: file sidecar '<domain>.custom.conf' \u2014 napp include n\xF3 v\xE0o cu\u1ED1i file t\u1EF1 | |
| sinh v\xE0 KH\xD4NG BAO GI\u1EDC ghi \u0111\xE8. \u0110\xE2y l\xE0 ch\u1ED7 \u0110\xDANG \u0111\u1EC3 \u0111\u1EB7t location ri\xEAng. | |
| - Tr\u01B0\u1EDBc khi ghi \u0111\xE8, napp so t\u1EADp ti\u1EC1n t\u1ED1 'location ^~' c\u0169 v\u1EDBi m\u1EDBi: ti\u1EC1n t\u1ED1 n\xE0o | |
| s\u1EAFp bi\u1EBFn m\u1EA5t th\xEC SAO L\u01AFU file c\u0169 ('.napp-orphaned') v\xE0 n\xF3i r\xF5 m\u1EA5t c\xE1i g\xEC, m\u1EA5t | |
| \u0111i \u0111\xE2u. C\u1ED1 \xFD so ti\u1EC1n t\u1ED1 ch\u1EE9 kh\xF4ng d\xF9ng fingerprint nh\u01B0 unit systemd: file c\u1EE7a | |
| app t\u1EA1o b\u1EB1ng b\u1EA3n napp c\u0169 kh\xF4ng c\xF3 fingerprint n\xE0o, d\xF9ng c\xE1ch \u0111\xF3 l\xE0 c\u1EA3nh b\xE1o sai | |
| h\xE0ng lo\u1EA1t ngay l\u1EA7n n\xE2ng c\u1EA5p \u0111\u1EA7u ti\xEAn. | |
| - M\u1EDAI: '--auto-static' nh\u1EADn di\u1EC7n lu\xF4n TH\u01AF M\u1EE4C FILE T\u1EA2I L\xCAN. Ch\u1EC9 nh\u1EADn ca AN TO\xC0N: | |
| th\u01B0 m\u1EE5c t\xEAn 'uploads'/'upload' n\u1EB1m NGAY TRONG g\u1ED1c t\u0129nh c\xF4ng khai c\u1EE7a framework | |
| ('static/' c\u1EE7a SvelteKit, 'public/' c\u1EE7a Next/Nuxt/Astro/Vite). Nh\u1EEFng th\u01B0 m\u1EE5c \u0111\xF3 | |
| theo \u0111\u1ECBnh ngh\u0129a c\u1EE7a framework \u0110\xC3 c\xF4ng khai (build sao ch\xE9p nguy\xEAn ch\xFAng v\xE0o | |
| output), n\xEAn ph\u1EE5c v\u1EE5 ch\xFAng KH\xD4NG m\u1EDF th\xEAm g\xEC \u2014 n\xF3 ch\u1EC9 v\xE1 \u0111\xFAng kho\u1EA3ng tr\u1ED1ng: file | |
| t\u1EA3i l\xEAn SAU l\u1EA7n build g\u1EA7n nh\u1EA5t kh\xF4ng c\xF3 trong output n\xEAn tr\u1EA3 404, r\u1ED3i t\u1EF1 hi\u1EC7n | |
| ra sau l\u1EA7n deploy k\u1EBF ti\u1EBFp, tr\xF4ng h\u1EC7t l\u1ED7i ch\u1EADp ch\u1EDDn. | |
| C\u1ED0 \xDD KH\xD4NG \u0111o\xE1n th\u01B0 m\u1EE5c NGO\xC0I g\u1ED1c t\u0129nh ('./uploads', './storage', './media'): | |
| ch\u1ED7 \u0111\xF3 app t\u1EF1 ch\u1ECDn, kh\xF4ng g\xEC b\u1EA3o \u0111\u1EA3m \u0111\u01B0\u1EE3c ph\xE9p c\xF4ng khai, v\xE0 \u0111o\xE1n sai l\xE0 \u0111em | |
| file ri\xEAng t\u01B0 ra \u0111\u01B0\u1EDDng. V\u1EABn khai b\xE1o tay \u0111\u01B0\u1EE3c b\u1EB1ng '--upload-dir'. | |
| - Ti\u1EC1n t\u1ED1 URL \u0111\u01B0\u1EE3c \u0111\u1EB7t k\xE8m theo th\u01B0 m\u1EE5c nh\u1EADn di\u1EC7n \u0111\u01B0\u1EE3c: th\u01B0 m\u1EE5c t\xEAn 'upload' (s\u1ED1 | |
| \xEDt) tr\u01B0\u1EDBc \u0111\xE2y s\u1EBD b\u1ECB ph\u1EE5c v\u1EE5 \u1EDF '/uploads/' v\xEC \u0111\xF3 l\xE0 m\u1EB7c \u0111\u1ECBnh c\u1EE7a renderer. | |
| - 'napp check' b\xE1o th\xEAm: app n\xE0o c\xF3 th\u01B0 m\u1EE5c t\u1EA3i l\xEAn trong g\u1ED1c t\u0129nh c\xF4ng khai m\xE0 | |
| nginx ch\u01B0a ph\u1EE5c v\u1EE5. '--fix' s\u1EEDa \u0111\u01B0\u1EE3c. | |
| - CH\u1EB6N HOTLINK M\u1EA0NH H\u01A0N H\u1EB2N: '--hotlink-protect' nay ph\xE1t th\xEAm header | |
| 'Cross-Origin-Resource-Policy: same-site' cho M\u1ECCI location asset (asset build | |
| l\u1EABn file t\u1EA3i l\xEAn), kh\xF4ng ch\u1EC9 ki\u1EC3m tra Referer nh\u01B0 tr\u01B0\u1EDBc. | |
| Kh\xE1c bi\u1EC7t c\u1ED1t l\xF5i: CORP do TR\xCCNH DUY\u1EC6T NG\u01AF\u1EDCI XEM th\u1EF1c thi d\u1EF1a tr\xEAn header do | |
| SERVER B\u1EA0N g\u1EEDi, n\xEAn trang hotlink KH\xD4NG t\xE1c \u0111\u1ED9ng \u0111\u01B0\u1EE3c \u2014 trong khi Referer l\xE0 | |
| th\u1EE9 ch\xEDnh trang \u0111\xF3 khai b\xE1o, ch\u1EC9 c\u1EA7n <meta name="referrer" content="no-referrer"> | |
| l\xE0 v\xF4 hi\u1EC7u to\xE0n b\u1ED9 valid_referers. CORP c\u0169ng S\u1ED0NG S\xD3T QUA CDN: Cloudflare cache | |
| theo URL r\u1ED3i tr\u1EA3 cho m\u1ECDi referer m\xE0 kh\xF4ng h\u1ECFi origin (l\xE0m ki\u1EC3m tra Referer \u1EDF | |
| origin g\u1EA7n nh\u01B0 v\xF4 d\u1EE5ng), c\xF2n CORP n\u1EB1m trong ch\xEDnh response \u0111\xE3 cache. | |
| V\xE0 n\xF3 KH\xD4NG ph\xE1 th\u1EE9 m\xE0 ch\u1EB7n Referer g\u1EAFt ph\xE1: bot l\u1EA5y \u1EA3nh preview (Facebook, | |
| Zalo, Telegram) t\u1EA3i \u1EA3nh \u1EDF ph\xEDa SERVER n\xEAn kh\xF4ng b\u1ECB \xE1p -> link chia s\u1EBB v\u1EABn c\xF3 | |
| \u1EA3nh; g\xF5 th\u1EB3ng URL \u1EA3nh l\xE0 \u0111i\u1EC1u h\u01B0\u1EDBng c\u1EA5p cao nh\u1EA5t n\xEAn c\u0169ng kh\xF4ng b\u1ECB ch\u1EB7n. | |
| D\xF9ng 'same-site' ch\u1EE9 kh\xF4ng 'same-origin' v\xEC napp t\u1EF1 th\xEAm alias 'www.<domain>' | |
| v\xE0 admin/api th\u01B0\u1EDDng \u1EDF subdomain kh\xE1c \u2014 'same-origin' s\u1EBD ch\u1EB7n ch\xEDnh site m\xECnh. | |
| - CORP KH\xD4NG \u0111\u01B0\u1EE3c ph\xE1t khi c\xF3 '--hotlink-allow': CORP ch\u1EC9 c\xF3 ba gi\xE1 tr\u1ECB, kh\xF4ng | |
| di\u1EC5n \u0111\u1EA1t \u0111\u01B0\u1EE3c danh s\xE1ch cho ph\xE9p theo domain, n\xEAn ph\xE1t ra l\xE0 ch\u1EB7n \u0111\xFAng nh\u1EEFng | |
| \u0111\u1ED1i t\xE1c v\u1EEBa cho ph\xE9p v\xE0 \u1EA3nh v\u1EE1 \u1EDF ph\xEDa h\u1ECD m\xE0 kh\xF4ng ai b\xE1o. napp N\xD3I R\xD5 khi r\u01A1i | |
| v\xE0o tr\u01B0\u1EDDng h\u1EE3p n\xE0y, k\xE8m h\u01B0\u1EDBng \u0111i th\u1EADt (URL k\xFD secure_link, ho\u1EB7c t\u1EA7ng CDN). | |
| - M\u1EDAI '--hotlink-strict': b\u1ECF 'none'/'blocked' kh\u1ECFi valid_referers. Ch\u1EB7t h\u01A1n | |
| nh\u01B0ng M\u1EA4T \u1EA3nh preview khi chia s\u1EBB link v\xE0 403 nh\u1EA7m ng\u01B0\u1EDDi d\xF9ng sau proxy c\xF4ng | |
| ty \u2014 napp c\u1EA3nh b\xE1o m\u1ED7i l\u1EA7n c\u1EDD n\xE0y b\u1EADt. | |
| ## 1.22.0 | |
| - M\u1EDAI '--auto-static': napp NH\u1EACN DI\u1EC6N FRAMEWORK t\u1EEB TH\u01AF M\u1EE4C BUILD r\u1ED3i cho nginx | |
| tr\u1EA3 th\u1EB3ng asset, thay v\xEC b\u1EAFt b\u1EA1n t\u1EF1 tra ti\u1EC1n t\u1ED1. Nh\u1EADn: SvelteKit adapter-node | |
| ('build/client/_app' -> /_app/), Next.js ('.next/static' -> /_next/static/), | |
| Nuxt 3/Nitro ('.output/public/_nuxt' -> /_nuxt/), SolidStart/Vinxi | |
| ('.output/public/_build' -> /_build/), Astro ('dist/client/_astro' ho\u1EB7c | |
| 'dist/_astro' -> /_astro/). D\xF9ng \u0111\u01B0\u1EE3c \u1EDF c\u1EA3 'app create' v\xE0 'app set'. | |
| C\u0103n c\u1EE9 l\xE0 TH\u01AF M\u1EE4C C\xD3 TH\u1EACT ch\u1EE9 kh\xF4ng ph\u1EA3i dependencies: package.json \u1EDF g\u1ED1c | |
| monorepo kh\xF4ng n\xF3i \u0111\u01B0\u1EE3c app con d\xF9ng adapter n\xE0o, v\xE0 c\xF9ng m\u1ED9t app SvelteKit | |
| th\xEC adapter-node sinh 'build/client' c\xF2n adapter-static sinh 'build' v\u1EDBi deps | |
| y h\u1EC7t. H\u1EC7 qu\u1EA3: ch\u1EC9 nh\u1EADn di\u1EC7n \u0111\u01B0\u1EE3c SAU khi build \u2014 ch\u01B0a build th\xEC b\xE1o kh\xF4ng | |
| nh\u1EADn ra, kh\xF4ng \u0111o\xE1n b\u1EEBa. | |
| - M\u1EDAI '--static-alias <ti\u1EC1n-t\u1ED1>=<th\u01B0-m\u1EE5c>': ph\u1EE5c v\u1EE5 b\u1EB1ng 'alias' thay v\xEC 'root'. | |
| C\u1EA7n cho Next.js \u2014 file \u1EDF '.next/static/\u2026' nh\u01B0ng URL l\xE0 '/_next/static/\u2026', n\xEAn | |
| 'root .next' \u0111i t\xECm '.next/_next/static/\u2026' v\xE0 TO\xC0N B\u1ED8 JS/CSS tr\u1EA3 404 (trang | |
| tr\u1EAFng). V\u1EDBi Next.js, napp CH\u1EC8 chi\u1EBFm '/_next/static/': '/_next/image' (t\u1ED1i \u01B0u | |
| \u1EA3nh l\xFAc request) v\xE0 '/_next/data' (payload \u0111i\u1EC1u h\u01B0\u1EDBng) PH\u1EA2I \u0111i qua Node. | |
| - '/assets/' (Remix \xB7 React Router v7 \xB7 Vite SPA) CH\u1EC8 \u0110\u01AF\u1EE2C G\u1EE2I \xDD, kh\xF4ng bao gi\u1EDD | |
| t\u1EF1 \xE1p \u2014 k\u1EC3 c\u1EA3 khi c\xF3 --auto-static. '/_app/', '/_next/', '/_nuxt/', '/_astro/' | |
| l\xE0 namespace ri\xEAng c\u1EE7a framework n\xEAn chi\u1EBFm \u0111\u01B0\u1EE3c an to\xE0n; '/assets/' th\xEC app | |
| ho\xE0n to\xE0n c\xF3 th\u1EC3 d\xF9ng l\xE0m route th\u1EADt, m\xE0 'location ^~' th\u1EAFng c\u1EA3 route regex | |
| l\u1EABn proxy_pass -> \xE1p nh\u1EA7m l\xE0 route \u0111\xF3 ch\u1EBFt h\u1EB3n b\u1EB1ng 404, kh\xF4ng log, kh\xF4ng l\u1ED7i. | |
| - S\u1EECA L\u1ED6I: asset t\u0129nh tr\u1EA3 403 ch\u1EE9 kh\xF4ng ph\u1EA3i file. Th\u01B0 m\u1EE5c app thu\u1ED9c user ri\xEAng | |
| v\xE0 \u0111\u1EC3 750, worker nginx ch\u1EA1y b\u1EB1ng user kh\xE1c (www-data) n\xEAn kh\xF4ng \u0111i xuy\xEAn qua | |
| n\u1ED5i /var/www/<domain> \u2014 ngh\u0129a l\xE0 M\u1ECCI c\u1EA5u h\xECnh --static-root/--upload-dir t\u1EEB | |
| tr\u01B0\u1EDBc t\u1EDBi nay \u0111\u1EC1u 403 tr\xEAn m\xE1y s\u1EA1ch, v\xE0 log nginx ghi 'Permission denied', r\u1EA5t | |
| d\u1EC5 \u0111\u1ECDc nh\u1EA7m th\xE0nh sai \u0111\u01B0\u1EDDng d\u1EABn. napp nay t\u1EF1 th\xEAm www-data v\xE0o NH\xD3M c\u1EE7a app | |
| r\u1ED3i RESTART nginx (reload kh\xF4ng \u0111\u1EE7: danh s\xE1ch nh\xF3m ch\u1EC9 \u0111\u1ECDc l\xFAc ti\u1EBFn tr\xECnh kh\u1EDFi | |
| t\u1EA1o). '.env' v\u1EABn an to\xE0n v\xEC \u0111\u1EC3 600. | |
| - 'napp check' nay b\xE1o hai th\u1EE9 m\u1EDBi, cho app \u0110ANG CH\u1EA0Y: (1) app n\xE0o c\xF2n \u0111\u1EA9y to\xE0n | |
| b\u1ED9 asset qua Node d\xF9 nh\u1EADn di\u1EC7n \u0111\u01B0\u1EE3c framework \u2014 lo\u1EA1i h\u1ECFng kh\xF4ng c\xF3 tri\u1EC7u ch\u1EE9ng | |
| n\xE0o ngo\xE0i 'v\xE0o dashboard th\u1EA5y gi\u1EF1t'; (2) app n\xE0o c\xF3 c\u1EA5u h\xECnh t\u0129nh m\xE0 nginx | |
| kh\xF4ng \u0111\u1ECDc \u0111\u01B0\u1EE3c. '--fix' s\u1EEDa \u0111\u01B0\u1EE3c c\u1EA3 hai (tr\u1EEB nh\xF3m '/assets/' r\u1EE7i ro). | |
| - Menu t\u01B0\u01A1ng t\xE1c: th\xEAm m\u1EE5c 'B\u1EADt nginx tr\u1EA3 asset t\u0129nh', v\xE0 b\u01B0\u1EDBc t\u1EA1o app c\xF3 h\u1ECFi | |
| lu\xF4n. C\u1ED0 \xDD h\u1ECFi ch\u1EE9 kh\xF4ng b\u1EADt ng\u1EA7m \u2014 napp \u0111ang chi\u1EBFm m\u1ED9t ti\u1EC1n t\u1ED1 URL. | |
| ## 1.21.0 | |
| - S\u1EEDa tay unit systemd KH\xD4NG c\xF2n b\u1ECB ghi \u0111\xE8. M\u1ED7i unit mang d\xF2ng | |
| '# napp-fingerprint:'; l\u1EC7ch fingerprint = \u0111\xE3 c\xF3 ng\u01B0\u1EDDi s\u1EEDa, napp so t\u1EEBng | |
| directive v\xE0 gi\u1EEF l\u1EA1i b\u1EA3n c\u1EE7a b\u1EA1n (ExecStart, StandardOutput/StandardError, | |
| User, Group, Restart, LimitNOFILE, Nice, MemoryMax...), ghi t\xEAn ch\xFAng v\xE0o | |
| '# napp-preserve:' \u0111\u1EC3 l\u1EA7n ghi sau v\u1EABn nh\u1EDB. B\u1EA1n c\u0169ng t\u1EF1 th\xEAm d\xF2ng \u0111\xF3 \u0111\u01B0\u1EE3c \u0111\u1EC3 | |
| kho\xE1 tr\u01B0\u1EDBc. Ph\u1EA7n hardening (ProtectSystem, ReadWritePaths...) C\u1ED0 \xDD kh\xF4ng n\u1EB1m | |
| trong danh s\xE1ch gi\u1EEF, \u0111\u1EC3 b\u1EA3n v\xE1 b\u1EA3o m\u1EADt c\xF2n \u0111\u01B0\u1EDDng lan t\u1EDBi unit c\u0169. | |
| - C\xE2n \u0111\u1ED1i heap V8 ch\u1EC9 s\u1EEDa \u0110\xDANG M\u1ED8T D\xD2NG: con s\u1ED1 trong --max-old-space-size c\u1EE7a | |
| Environment=NODE_OPTIONS. Kh\xF4ng render l\u1EA1i unit, kh\xF4ng \u0111\u1EE5ng d\xF2ng n\xE0o kh\xE1c. | |
| Tr\u01B0\u1EDBc \u0111\xE2y 'napp tune apply' \u2014 v\xE0 c\u1EA3 vi\u1EC7c t\u1EA1o th\xEAm m\u1ED9t app, v\xEC heap chia theo | |
| t\u1ED5ng s\u1ED1 \u0111\u01A1n v\u1ECB node \u2014 \u0111\u1EC1u ghi \u0111\xE8 c\u1EA3 file, th\u1ED5i bay c\u1EA5u h\xECnh s\u1EEDa tay v\xE0 l\xE0m app | |
| ch\u1EBFt ngay l\xFAc restart. Ch\u1EC9 restart unit th\u1EF1c s\u1EF1 \u0111\u1ED5i s\u1ED1 (app bun kh\xF4ng d\xF9ng c\u1EDD | |
| heap c\u1EE7a V8 n\xEAn kh\xF4ng c\xF2n b\u1ECB restart v\xF4 \xEDch). | |
| - M\u1EDAI: 'napp tune apply --sync-units' \u2014 render l\u1EA1i to\xE0n b\u1ED9 unit t\u1EEB template \u0111\u1EC3 | |
| \u0111\u1EA9y hardening/template m\u1EDBi xu\u1ED1ng unit t\u1EA1o t\u1EEB b\u1EA3n napp c\u0169. Vi\u1EC7c n\xE0y C\u0168 V\u1EAAN L\xC0M | |
| NG\u1EA6M, nay ph\u1EA3i g\xF5 ra. Directive b\u1EA1n s\u1EEDa tay v\u1EABn \u0111\u01B0\u1EE3c gi\u1EEF. | |
| - Directive ch\xEDnh b\u1EA1n v\u1EEBa ra l\u1EC7nh \u0111\u1ED5i th\xEC napp v\u1EABn l\xE0m ch\u1EE7: 'napp service set | |
| --run-as' \u0111\u1EB7t l\u1EA1i User/Group, unit backup/cloudflare \u0111\u1EB7t l\u1EA1i ExecStart (n\xF3 | |
| mang ch\xEDnh c\xE1c tu\u1EF3 ch\u1ECDn b\u1EA1n truy\u1EC1n). napp B\xC1O R\xD5 directive n\xE0o v\u1EEBa b\u1ECB \u0111\u1EB7t l\u1EA1i. | |
| ## 1.20.1 | |
| - 'napp --help' c\xF3 ph\u1EA7n V\xCD D\u1EE4 \u1EDF cu\u1ED1i, g\u1ED3m m\u1EE5c "sau khi c\u1EADp nh\u1EADt napp": danh | |
| s\xE1ch l\u1EC7nh t\u1EF1 sinh tr\u1EA3 l\u1EDDi \u0111\u01B0\u1EE3c "c\xF3 l\u1EC7nh g\xEC" nh\u01B0ng kh\xF4ng nh\u1EAFc c\xE1c b\u01B0\u1EDBc B\u1EAET | |
| BU\u1ED8C sau n\xE2ng c\u1EA5p, m\xE0 b\u1ECF qua th\xEC server v\u1EABn mang c\u1EA5u h\xECnh c\u0169 \u0111\xE3 h\u1ECFng | |
| (volatile-lru m\u1EA5t job BullMQ, b\u1ED9 \u0111\u1EC7m 16k l\xE0m route SvelteKit s\xE2u tr\u1EA3 502). | |
| - Menu t\u01B0\u01A1ng t\xE1c c\xF3 m\u1EE5c "\u0110\u1ED3ng b\u1ED9 c\u1EA5u h\xECnh proxy nginx v\xE0o vhost \u0111\xE3 c\xF3" (m\u1EE5c 10 | |
| nh\xF3m H\u1EA1 t\u1EA7ng). Tr\u01B0\u1EDBc \u0111\xF3 'napp nginx sync' KH\xD4NG c\xF3 trong menu n\xEAn ng\u01B0\u1EDDi ch\u1EC9 | |
| d\xF9ng menu kh\xF4ng c\xF3 \u0111\u01B0\u1EDDng n\xE0o ch\u1EA1m t\u1EDBi b\u01B0\u1EDBc s\u1EEDa 502. L\u01B0u \xFD: "Xem \u0111\u1EC1 xu\u1EA5t t\u1ED1i | |
| \u01B0u ph\u1EA7n c\u1EE9ng" xu\u1ED1ng 11, "\xC1p t\u1ED1i \u01B0u ph\u1EA7n c\u1EE9ng" xu\u1ED1ng 12. | |
| - M\xF4 t\u1EA3 l\u1EC7nh c\u1EADp nh\u1EADt cho kh\u1EDBp th\u1EF1c t\u1EBF: nginx, nginx sync, check, service create. | |
| ## 1.20.0 | |
| - S\u1EECA: route SvelteKit L\u1ED2NG S\xC2U tr\u1EA3 502 v\xEC b\u1ED9 \u0111\u1EC7m proxy qu\xE1 nh\u1ECF. | |
| proxy_buffer_size l\xE0 b\u1ED9 \u0111\u1EC7m ch\u1EE9a TO\xC0N B\u1ED8 KH\u1ED0I HEADER c\u1EE7a response; v\u01B0\u1EE3t qu\xE1 | |
| l\xE0 nginx c\u1EAFt k\u1EBFt n\u1ED1i, tr\u1EA3 502 v\xE0 ghi 'upstream sent too big header while | |
| reading response header from upstream'. App ph\xEDa sau v\u1EABn kho\u1EBB (curl th\u1EB3ng | |
| 127.0.0.1:<port> ra \u0111\xFAng) n\xEAn r\u1EA5t d\u1EC5 \u0111\u1ED5 l\u1ED7i nh\u1EA7m cho Node. SvelteKit \u0111\u1EE5ng | |
| tr\u1EA7n \u1EDF route s\xE2u v\xEC m\u1ED7i t\u1EA7ng layout/page g\xF3p th\xEAm m\u1EE5c 'Link: rel=modulepreload' | |
| v\xE0o header, t\xEAn file l\u1EA1i c\xF3 hash d\xE0i; c\u1ED9ng Set-Cookie phi\xEAn \u0111\u0103ng nh\u1EADp l\xE0 ch\u1EA1m | |
| 16k d\u1EC5 nh\u01B0 kh\xF4ng. Gi\xE1 tr\u1ECB m\u1EDBi: proxy_buffer_size 128k, proxy_buffers 4 256k, | |
| proxy_busy_buffers_size 256k. | |
| - B\u1ED9 \u0111\u1EC7m chuy\u1EC3n l\xEAn M\u1EE8C HTTP, \u0111\u1EB7t M\u1ED8T CH\u1ED6 trong | |
| /etc/nginx/conf.d/00-napp-proxy.conf. Tr\u01B0\u1EDBc \u0111\xE2y m\u1ED7i vhost mang m\u1ED9t b\u1EA3n sao | |
| trong 'location /' -> m\u1ED7i l\u1EA7n \u0111\u1ED5i ph\u1EA3i s\u1EEDa vhost, m\xE0 vhost l\xE0 ch\u1ED7 certbot | |
| ch\xE8n kh\u1ED1i SSL, render l\u1EA1i l\xE0 m\u1EA5t HTTPS. | |
| - 'napp nginx sync' G\u1EE0 kh\u1ED1i b\u1ED9 \u0111\u1EC7m n\u1ED9i tuy\u1EBFn kh\u1ECFi vhost c\u0169 \u2014 B\u1EAET BU\u1ED8C v\xEC gi\xE1 | |
| tr\u1ECB trong 'location' lu\xF4n th\u1EAFng gi\xE1 tr\u1ECB m\u1EE9c http, kh\xF4ng g\u1EE1 th\xEC site c\u0169 v\u1EABn | |
| 16k v\xE0 v\u1EABn 502. C\u1EAFt theo D\xD2NG, kh\xF4ng render l\u1EA1i vhost: kh\u1ED1i SSL c\u1EE7a certbot | |
| c\xF2n nguy\xEAn. C\xF3 sao l\u01B0u + ho\xE0n t\xE1c n\u1EBFu 'nginx -t' tr\u01B0\u1EE3t. | |
| ## 1.19.0 | |
| - 'napp service create --run-as <domain|name>': worker ch\u1EA1y b\u1EB1ng USER C\u1EE6A APP | |
| WEB \u0111\xE3 c\xF3 thay v\xEC user ri\xEAng. C\u1EA7n khi worker \u0111\u1EE5ng FILE c\u1EE7a app (n\xE9n \u1EA3nh trong | |
| th\u01B0 m\u1EE5c upload, thumbnail, d\u1ECDn cache): th\u01B0 m\u1EE5c app l\xE0 750 / file 640 c\u1EE7a user | |
| app n\xEAn user ri\xEAng \u0110\u1ECCC C\xD2N KH\xD4NG N\u1ED4I, m\xE0 n\u1EDBi quy\u1EC1n ra cho hai user l\xE0 m\u1EDF lu\xF4n | |
| cho m\u1ECDi th\u1EE9 kh\xE1c. --run-as g\u1EE1 C\u1EA2 HAI l\u1EDBp ch\u1EB7n: quy\u1EC1n Unix (thi\u1EBFu -> EACCES) | |
| v\xE0 sandbox systemd ProtectSystem=strict (thi\u1EBFu -> EROFS d\xF9 ls -l tr\xF4ng \u0111\xFAng). | |
| Kh\xF4ng truy\u1EC1n th\xEC service v\u1EABn c\xF3 user ri\xEAng, c\xF4 l\u1EADp nh\u01B0 c\u0169. | |
| - '--write-dir <path>' (l\u1EB7p l\u1EA1i \u0111\u01B0\u1EE3c): c\u1EA5p quy\u1EC1n ghi v\xE0o \u0111\u01B0\u1EDDng d\u1EABn ngo\xE0i m\xE3 | |
| ngu\u1ED3n service. \u0110\u01B0\u1EDDng d\u1EABn ph\u1EA3i T\u1ED2N T\u1EA0I \u2014 systemd t\u1EEB ch\u1ED1i kh\u1EDFi \u0111\u1ED9ng unit n\u1EBFu | |
| ReadWritePaths tr\u1ECF v\xE0o ch\u1ED7 kh\xF4ng c\xF3, n\xEAn napp ki\u1EC3m tra ngay l\xFAc t\u1EA1o. | |
| - 'napp service set <name> [--run-as <u>|--standalone] [--write-dir <p>]': \u0111\u1ED5i | |
| danh t\xEDnh/quy\u1EC1n ghi c\u1EE7a service \u0110\xC3 T\u1EA0O (chown m\xE3 ngu\u1ED3n, ghi l\u1EA1i unit, | |
| restart). Tr\u01B0\u1EDBc \u0111\xE2y ph\u1EA3i xo\xE1 \u0111i t\u1EA1o l\u1EA1i, m\u1EA5t .env. | |
| - napp KH\xD4NG BAO GI\u1EDC xo\xE1 user \u0111i m\u01B0\u1EE3n: 'service remove --source' gi\u1EEF user, v\xE0 | |
| 'app remove --source' t\u1EEB ch\u1ED1i xo\xE1 user khi c\xF2n worker \u0111ang m\u01B0\u1EE3n (k\xE8m danh | |
| s\xE1ch worker ph\u1EA3i g\u1EE1 tr\u01B0\u1EDBc). | |
| - \u0110\xC1NH \u0110\u1ED4I: d\xF9ng chung user = d\xF9ng chung DANH T\xCDNH UNIX. Worker \u0111\u1ECDc/ghi \u0111\u01B0\u1EE3c | |
| m\u1ECDi th\u1EE9 c\u1EE7a app web k\u1EC3 c\u1EA3 .env; m\u1ED9t b\xEAn b\u1ECB chi\u1EBFm quy\u1EC1n l\xE0 b\xEAn kia m\u1EA5t theo. | |
| Ch\u1EC9 d\xF9ng cho hai n\u1EEDa c\u1EE7a C\xD9NG m\u1ED9t s\u1EA3n ph\u1EA9m. | |
| ## 1.18.0 | |
| - S\u1EECA: Redis maxmemory-policy volatile-lru -> noeviction. BullMQ ki\u1EC3m tra l\xFAc | |
| k\u1EBFt n\u1ED1i v\xE0 b\xE1o 'IMPORTANT! Eviction policy is volatile-lru. It should be | |
| "noeviction"'. D\u1EEF li\u1EC7u h\xE0ng \u0111\u1EE3i KH\xD4NG ph\u1EA3i cache: job \u0111ang ch\u1EDD, kho\xE1, k\u1EBFt qu\u1EA3 | |
| ch\u1EC9 c\xF3 m\u1ED9t b\u1EA3n. V\u1EDBi ch\xEDnh s\xE1ch *-lru, ch\u1EA1m maxmemory l\xE0 Redis T\u1EF0 TR\u1EE4C XU\u1EA4T | |
| key \u2014 job b\u1ED1c h\u01A1i gi\u1EEFa ch\u1EEBng, KH\xD4NG b\xEAn n\xE0o b\xE1o l\u1ED7i. volatile-lru c\u0169ng kh\xF4ng | |
| tho\xE1t: BullMQ C\xD3 \u0111\u1EB7t TTL cho kho\xE1, rate-limit, job \u0111\xE3 xong. noeviction khi\u1EBFn | |
| Redis T\u1EEA CH\u1ED0I l\u1EC7nh ghi (OOM) khi \u0111\u1EA7y \u2014 h\u1ECFng l\u1ED9 li\u1EC5u h\u01A1n l\xE0 m\u1EA5t vi\u1EC7c trong im | |
| l\u1EB7ng. Ch\xEDnh s\xE1ch \xE1p cho C\u1EA2 INSTANCE, kh\xF4ng t\xE1ch theo DB index. | |
| \u0110\xE1nh \u0111\u1ED5i: Redis \u0111\u1EA7y th\xEC ghi m\u1EDBi l\u1ED7i OOM ch\u1EE9 kh\xF4ng t\u1EF1 d\u1ECDn -> \u0111\u1EB7t TTL cho key | |
| cache (key h\u1EBFt h\u1EA1n v\u1EABn b\u1ECB xo\xE1) v\xE0 theo d\xF5i 'napp redis info'. | |
| - 'napp check' \u0111\u1ECDc maxmemory-policy \u0110ANG CH\u1EA0Y v\xE0 b\xE1o n\u1EBFu kh\xE1c noeviction \u2014 | |
| server \u0111\xE3 tune b\u1EB1ng b\u1EA3n c\u0169 v\u1EABn \u0111ang \u0111\u1EC3 volatile-lru, sinh l\u1EA1i template kh\xF4ng | |
| ch\u1EA1m t\u1EDBi ch\xFAng. 'napp check --fix' \xE1p ngay b\u1EB1ng CONFIG SET v\xE0 ghi v\xE0o | |
| /etc/redis/conf.d/napp-tuning.conf (b\u1EC1n qua restart), KH\xD4NG restart Redis. | |
| ## 1.17.0 | |
| - ADDRESS_HEADER/XFF_DEPTH gi\u1EDD l\xE0 TU\u1EF2 CH\u1ECCN ('--address-header'), kh\xF4ng c\xF2n m\u1EB7c | |
| \u0111\u1ECBnh. Ch\xFAng \u0111\u1ED5i th\u1EE9 getClientAddress() c\u1EE7a adapter-node tr\u1EA3 v\u1EC1: t\u1EEB \u0110\u1ECAA CH\u1EC8 | |
| SOCKET sang gi\xE1 tr\u1ECB PARSE T\u1EEA HEADER \u2014 ph\xE1 app n\xE0o t\u1EF1 ph\xE2n gi\u1EA3i IP kh\xE1ch (l\u1EA5y | |
| socket peer, \u0111\u1ED1i chi\u1EBFu proxy tin c\u1EADy, R\u1ED2I m\u1EDBi tin header). H\u1EC7 qu\u1EA3: app spam | |
| log "ignoring forwarding headers from untrusted peer ..." m\u1ED7i request v\xE0 r\u01A1i | |
| v\u1EC1 tin b\u1EA5t c\u1EE9 th\u1EE9 g\xEC XFF_DEPTH ch\u1ECDn. IP th\u01B0\u1EDDng v\u1EABn ra \u0110\xDANG, n\xEAn nguy hi\u1EC3m: | |
| t\xEDnh \u0111\xFAng \u0111\u1EAFn ph\u1EE5 thu\u1ED9c ho\xE0n to\xE0n v\xE0o XFF_DEPTH kh\u1EDBp s\u1ED1 hop th\u1EADt, th\xEAm m\u1ED9t | |
| hop sau n\xE0y l\xE0 l\u1EB7ng l\u1EBD \u0111\u1ECDc ph\u1EA3i m\u1EE5c CLIENT GI\u1EA2 M\u1EA0O \u0110\u01AF\u1EE2C. Gi\xE1 tr\u1ECB \u0111\xF3 th\u01B0\u1EDDng l\xE0 | |
| kho\xE1 rate limiter -> h\u1ECFng ngh\u0129a l\xE0 \u0111\u0103ng nh\u1EADp sai KH\xD4NG GI\u1EDAI H\u1EA0N. | |
| App \u0111\xE3 t\u1EA1o kh\xF4ng \u0111\u1ED5i g\xEC; mu\u1ED1n g\u1EE1 th\xEC xo\xE1 2 d\xF2ng kh\u1ECFi .env r\u1ED3i 'napp app | |
| restart <domain>'. | |
| - S\u1EEDa ch\xFA th\xEDch SAI v\u1EC1 XFF_DEPTH: b\u1EA3n c\u0169 ghi "c\xF3 CDN tr\u01B0\u1EDBc nginx th\xEC t\u0103ng l\xEAn | |
| 2". Sai khi nginx \u0111\xE3 b\u1EADt Cloudflare real-IP \u2014 $remote_addr \u0110\xC3 l\xE0 IP kh\xE1ch | |
| th\u1EADt n\xEAn $proxy_add_x_forwarded_for n\u1ED1i th\xEAm ch\xEDnh n\xF3, v\u1EABn l\xE0 1. | |
| ## 1.16.0 | |
| - 'napp app set <domain>': \u0110\u1ED4I C\u1EA4U H\xCCNH NGINX C\u1EE6A APP \u0110\xC3 T\u1EA0O. C\xE1c tu\u1EF3 ch\u1ECDn th\xEAm | |
| \u1EDF 1.15.0 (--static-root/--upload-dir/--hotlink-protect/--max-body) tr\u01B0\u1EDBc \u0111\xF3 | |
| ch\u1EC9 \xE1p d\u1EE5ng l\xFAc T\u1EA0O app; 'napp nginx sync' kh\xF4ng gi\xFAp \u0111\u01B0\u1EE3c v\xEC n\xF3 ch\u1EC9 v\xE1 \u0111\xFAng | |
| m\u1ED9t chu\u1ED7i ch\u1EE9 kh\xF4ng render l\u1EA1i vhost \u2014 c\u1ED1 \xFD nh\u01B0 v\u1EADy, v\xEC certbot ch\xE8n kh\u1ED1i SSL | |
| th\u1EB3ng v\xE0o vhost n\xEAn render l\u1EA1i l\xE0 xo\xE1 HTTPS \u0111ang ch\u1EA1y. | |
| - Location ri\xEAng c\u1EE7a app chuy\u1EC3n sang FILE INCLUDE | |
| (/etc/nginx/napp-locations/<domain>.conf): vhost ch\u1EC9 mang \u0110\xDANG M\u1ED8T d\xF2ng | |
| include, n\xEAn m\u1ECDi l\u1EA7n \u0111\u1ED5i c\u1EA5u h\xECnh v\u1EC1 sau ch\u1EC9 ghi l\u1EA1i m\u1ED9t file v\xE0 KH\xD4NG BAO | |
| GI\u1EDC ch\u1EA1m v\xE0o vhost. D\xF2ng include \u0111\u01B0\u1EE3c ch\xE8n v\xE0o kh\u1ED1i server \u0111ang proxy t\u1EDBi | |
| upstream c\u1EE7a app (d\xF2 b\u1EB1ng \u0111\u1EBFm ngo\u1EB7c), m\u1ED9t l\u1EA7n duy nh\u1EA5t, idempotent, c\xF3 sao | |
| l\u01B0u + ho\xE0n t\xE1c n\u1EBFu 'nginx -t' tr\u01B0\u1EE3t. | |
| - S\u1EECA: 'napp domain add/remove' \xE2m th\u1EA7m l\xE0m M\u1EA4T HTTPS \u2014 regenerateNginxConf ghi | |
| \u0111\xE8 to\xE0n b\u1ED9 vhost n\xEAn kh\u1ED1i SSL c\u1EE7a certbot bi\u1EBFn m\u1EA5t, site t\u1EE5t v\u1EC1 HTTP m\xE0 kh\xF4ng | |
| b\xE1o g\xEC. Nay c\xF3 c\u1EA3nh b\xE1o r\xF5 k\xE8m l\u1EC7nh c\u1EA5p l\u1EA1i, v\xE0 sao l\u01B0u + ho\xE0n t\xE1c. | |
| - S\u1EECA: app t\u1EA1o b\u1EB1ng b\u1EA3n c\u0169 c\xF3 th\u1EC3 l\xE0m S\u1EACP NGINX TO\xC0N M\xC1Y \u2014 vhost nay include | |
| file location, m\xE0 nginx t\u1EEB ch\u1ED1i kh\u1EDFi \u0111\u1ED9ng n\u1EBFu include tr\u1ECF v\xE0o file kh\xF4ng t\u1ED3n | |
| t\u1EA1i. 'app create' v\xE0 'napp domain' \u0111\u1EC1u ghi file n\xE0y TR\u01AF\u1EDAC khi ghi vhost, k\u1EC3 c\u1EA3 | |
| khi app kh\xF4ng b\u1EADt tu\u1EF3 ch\u1ECDn n\xE0o. 'app remove' v\xE0 rollback \u0111\u1EC1u d\u1ECDn file. | |
| - NginxAppOptions kh\xF4ng c\xF2n b\u1EA3n sao c\u1EE7a staticRoot/uploadDir/hotlink* \u2014 ch\xFAng | |
| ch\u1EC9 n\u1EB1m tr\xEAn AppRecord, tr\xE1nh d\u1EF1ng l\u1EA1i c\xE1i b\u1EABy "tham s\u1ED1 kh\xF4ng ai \u0111\u1ECDc". | |
| ## 1.15.0 | |
| - C\u1EB6P WEB + WORKER D\xD9NG CHUNG REDIS DB: '--share-redis-with <domain|name>' v\xE0 | |
| '--redis-db <n>'. Tr\u01B0\u1EDBc \u0111\xE2y '--redis' lu\xF4n c\u1EA5p index r\u1EA3nh k\u1EBF ti\u1EBFp, n\xEAn web app | |
| v\xE0 worker ra HAI DB kh\xE1c nhau. H\xE0ng \u0111\u1EE3i ch\u1EC9 ch\u1EA1y khi b\xEAn \u0111\u1EA9y v\xE0 b\xEAn ti\xEAu th\u1EE5 | |
| nh\xECn C\xD9NG keyspace: kh\xE1c DB th\xEC web \u0111\u1EA9y job v\xE0o #1, worker nghe #2, KH\xD4NG B\xCAN | |
| N\xC0O B\xC1O L\u1ED6I \u2014 job ch\u1EA5t \u0111\u1ED1ng, email/th\xF4ng b\xE1o/resize im l\u1EB7ng kh\xF4ng ch\u1EA1y. T\u1EA1o | |
| service v\u1EDBi '--redis' m\xE0 kh\xF4ng ch\u1EC9 \u0111\u1ECBnh d\xF9ng chung th\xEC napp c\u1EA3nh b\xE1o t\u1EA1i ch\u1ED7. | |
| Xo\xE1 m\u1ED9t \u0111\u01A1n v\u1ECB KH\xD4NG c\xF2n tr\u1EA3 index v\u1EC1 danh s\xE1ch tr\u1ED1ng khi \u0111\u01A1n v\u1ECB kh\xE1c v\u1EABn d\xF9ng. | |
| - '--static-root <dir>' + '--static-prefix <path...>': \u0111\u1EC3 NGINX tr\u1EA3 asset thay v\xEC | |
| Node. Vhost tr\u01B0\u1EDBc \u0111\xE2y kh\xF4ng c\xF3 'root' n\xE0o n\xEAn M\u1ECCI file (.js/.css/.woff2) \u0111\u1EC1u \u0111i | |
| qua Node \u2014 m\u1ED9t trang SSR/SPA k\xE9o h\xE0ng tr\u0103m chunk, t\u1EA5t c\u1EA3 x\u1EBFp h\xE0ng tr\xEAn event | |
| loop \u0111\u01A1n lu\u1ED3ng v\xE0 tranh v\u1EDBi ch\xEDnh vi\u1EC7c render. Ch\u1EC9 ph\u1EE5c v\u1EE5 theo TI\u1EC0N T\u1ED0 khai | |
| b\xE1o (SvelteKit /_app/ \xB7 Next.js /_next/static/ \xB7 Vite /assets/), kh\xF4ng d\xF9ng | |
| try_files chung cho 'location /'. | |
| - '--upload-dir <dir>' (+ '--upload-prefix', m\u1EB7c \u0111\u1ECBnh /uploads/): FILE NG\u01AF\u1EDCI | |
| D\xD9NG T\u1EA2I L\xCAN kh\xF4ng ph\u1EA3i asset build, '--static-root' KH\xD4NG thay \u0111\u01B0\u1EE3c. V\u1EDBi | |
| SvelteKit adapter-node, 'static/' \u0111\u01B0\u1EE3c SAO CH\xC9P v\xE0o build/client L\xDAC BUILD v\xE0 | |
| l\xFAc ch\u1EA1y server ch\u1EC9 ph\u1EE5c v\u1EE5 build/client \u2014 n\xEAn \u1EA3nh t\u1EA3i l\xEAn SAU khi build tr\u1EA3 | |
| 404 d\xF9 file c\xF3 th\u1EADt tr\xEAn \u0111\u0129a, r\u1ED3i T\u1EF0 NHI\xCAN hi\u1EC7n ra sau l\u1EA7n deploy k\u1EBF ti\u1EBFp (v\xEC | |
| build l\u1EA1i sao ch\xE9p static/), tr\xF4ng nh\u01B0 l\u1ED7i ch\u1EADp ch\u1EDDn ch\u1EE9 kh\xF4ng nh\u01B0 l\u1ED7i c\u1EA5u | |
| h\xECnh. Cache-Control \u1EDF \u0111\xE2y c\u1ED1 \xFD NG\u1EAEN (1 ng\xE0y) v\xE0 KH\xD4NG 'immutable': t\xEAn file | |
| t\u1EA3i l\xEAn kh\xF4ng b\u0103m n\u1ED9i dung n\xEAn c\xF9ng m\u1ED9t URL c\xF3 th\u1EC3 \u0111\u1ED5i n\u1ED9i dung. | |
| - '--hotlink-protect' (+ '--hotlink-allow <domain...>'): ch\u1EC9 cho nh\xFAng \u1EA3nh | |
| trong --upload-dir t\u1EEB domain c\u1EE7a site. D\xF9ng 'valid_referers ... server_names' | |
| n\xEAn th\xEAm domain ph\u1EE5 l\xE0 t\u1EF1 \u0111\u1ED9ng \u0111\u01B0\u1EE3c ph\xE9p. 'none' v\xE0 'blocked' \u0110\u01AF\u1EE2C PH\xC9P c\xF3 | |
| ch\u1EE7 \u0111\xEDch: 'none' g\u1ED3m c\u1EA3 bot l\u1EA5y \u1EA3nh xem tr\u01B0\u1EDBc khi chia s\u1EBB link (Facebook/ | |
| Zalo/Telegram th\u01B0\u1EDDng kh\xF4ng g\u1EEDi Referer) \u2014 ch\u1EB7n n\xF3 l\xE0 m\u1EA5t \u1EA3nh preview \u1EDF m\u1ECDi | |
| link chia s\u1EBB. GI\u1EDAI H\u1EA0N: Referer do tr\xECnh duy\u1EC7t t\u1EF1 khai (trang hotlink \u0111\u1EB7t | |
| <meta name="referrer" content="no-referrer"> l\xE0 qua \u0111\u01B0\u1EE3c) n\xEAn \u0111\xE2y ch\u1EB7n | |
| hotlink TU\u1EF2 TI\u1EC6N ch\u1EE9 kh\xF4ng ph\u1EA3i ki\u1EC3m so\xE1t truy c\u1EADp; v\xE0 n\u1EBFu c\xF3 CDN \u0111\u1EE9ng tr\u01B0\u1EDBc | |
| th\xEC CDN cache theo URL, kh\xF4ng quan t\xE2m Referer, n\xEAn ch\u1EC9 t\xE1c d\u1EE5ng v\u1EDBi l\u1EA7n | |
| cache MISS \u2014 mu\u1ED1n ch\u1EB7n th\u1EADt ph\u1EA3i b\u1EADt \u1EDF t\u1EA7ng CDN. | |
| - '--max-body <size>' TH\u1EF0C S\u1EF0 c\xF3 t\xE1c d\u1EE5ng: client_max_body_size v\u1EABn lu\xF4n l\xE0 20M | |
| v\xEC kh\xF4ng ch\u1ED7 g\u1ECDi n\xE0o truy\u1EC1n tham s\u1ED1 \u0111\xE3 c\xF3 s\u1EB5n -> upload l\u1EDBn h\u01A1n b\u1ECB ch\u1EB7n 413 | |
| tr\u01B0\u1EDBc khi t\u1EDBi app. Nay gi\xE1 tr\u1ECB n\u1EB1m trong b\u1EA3n ghi app, template \u0111\u1ECDc th\u1EB3ng t\u1EEB \u0111\xF3. | |
| - '--app-dir <path>': ch\u1EA1y \u0111\u01B0\u1EE3c app trong MONOREPO. WorkingDirectory v\xE0 | |
| EnvironmentFile tr\u1ECF v\xE0o th\u01B0 m\u1EE5c con thay v\xEC g\u1ED1c repo. V\u1EDBi pnpm, ch\u1EA1y t\u1EEB g\u1ED1c | |
| repo khi\u1EBFn m\u1ED9t g\xF3i C\xD3 TH\u1EACT v\u1EABn b\xE1o ERR_MODULE_NOT_FOUND (Node \u0111i ng\u01B0\u1EE3c l\xEAn t\u1EEB | |
| file g\u1ECDi, pnpm ch\u1EC9 symlink v\xE0o node_modules c\u1EE7a package \u0111\xF3); v\xE0 v\xEC | |
| EnvironmentFile c\xF3 ti\u1EC1n t\u1ED1 '-', .env sai ch\u1ED7 khi\u1EBFn app kh\u1EDFi \u0111\u1ED9ng R\u1ED6NG bi\u1EBFn m\xF4i | |
| tr\u01B0\u1EDDng m\xE0 kh\xF4ng in l\u1ED7i. ReadWritePaths v\u1EABn l\xE0 G\u1ED0C m\xE3 ngu\u1ED3n. | |
| - 'gzip_proxied any' trong napp-tuning.conf. Ch\u1EC9 th\u1ECB n\xE0y \xE1p d\u1EE5ng khi REQUEST C\u1EE6A | |
| CLIENT mang header 'Via' (kh\xF4ng ph\u1EA3i "ph\u1EA3n h\u1ED3i t\u1EEB upstream"). \u0110o tr\xEAn trang | |
| 132 KB: kh\xF4ng Via th\xEC c\u1EA3 hai \u0111\u1EC1u n\xE9n; C\xD3 Via th\xEC thi\u1EBFu d\xF2ng n\xE0y tr\u1EA3 nguy\xEAn | |
| 132 KB. Cloudflare kh\xF4ng g\u1EEDi Via, nh\u01B0ng Fastly/Varnish/squid th\xEC c\xF3. | |
| - B\u1ED9 \u0111\u1EC7m proxy \u0111\u1EE7 cho trang SSR: proxy_buffer_size 8k->16k, proxy_buffers | |
| 8x8k -> 16x16k. Ph\u1EA7n v\u01B0\u1EE3t b\u1ED9 \u0111\u1EC7m b\u1ECB nginx ghi ra FILE T\u1EA0M tr\xEAn \u0111\u0129a r\u1ED3i \u0111\u1ECDc | |
| l\u1EA1i, m\u1ED7i request m\u1ED9t l\u1EA7n. | |
| - App/service \u0110\xC3 T\u1EA0O kh\xF4ng \u0111\u1ED5i h\xE0nh vi. \xC1p ph\u1EA7n nginx cho app \u0111ang ch\u1EA1y: | |
| 'napp nginx sync'. | |
| ## 1.14.0 | |
| - Th\xEAm 'napp doctor' \u2014 soi R\u1EE6I RO B\u1EA2O M\u1EACT (kh\xE1c 'napp check' v\u1ED1n ch\u1EC9 h\u1ECFi m\xF4i | |
| tr\u01B0\u1EDDng \u0111\xE3 \u0110\u1EE6 ch\u01B0a). C\xF3 trong menu t\u01B0\u01A1ng t\xE1c, m\u1EE5c 9. | |
| - 'napp doctor system': li\u1EC7t k\xEA g\xF3i c\xF3 B\u1EA2N V\xC1 B\u1EA2O M\u1EACT \u0111ang ch\u1EDD (\u0111\u1ECDc t\u1EEB kho | |
| '-security' c\u1EE7a apt, \u0111\xE1nh d\u1EA5u g\xF3i tr\u1ECDng y\u1EBFu: nginx/OpenSSL/OpenSSH/libc/ | |
| MariaDB/Redis/Node.js/certbot); ph\xE1t hi\u1EC7n d\u1ECBch v\u1EE5 \u0110\xC3 V\xC1 NH\u01AFNG CH\u01AFA RESTART | |
| (c\xF2n n\u1EA1p th\u01B0 vi\u1EC7n c\u0169 trong RAM \u2014 \u0111\u1ECDc /proc/<pid>/maps t\xECm file '(deleted)', | |
| kh\xF4ng c\u1EA7n c\xE0i needrestart); \u0111\u1ED1i chi\u1EBFu phi\xEAn b\u1EA3n nginx v\u1EDBi b\u1EA3ng CVE n\u1ED5i b\u1EADt | |
| (CVE-2021-23017 RCE qua resolver, HTTP/2 Rapid Reset, module mp4, mTLS | |
| session resumption...); c\u1EA3nh b\xE1o Node.js \u0111\xE3 EOL (h\u1EBFt nh\u1EADn b\u1EA3n v\xE1); b\xE1o khi | |
| m\xE1y c\u1EA7n reboot. | |
| - CVE c\u1EE7a nginx \u0111\u01B0\u1EE3c K\u1EBET LU\u1EACN B\u1EB0NG B\u1EB0NG CH\u1EE8NG tr\xEAn m\xE1y, kh\xF4ng ch\u1EC9 so s\u1ED1 phi\xEAn | |
| b\u1EA3n: '[\u0110\xC3 V\xC1]' khi m\xE3 CVE c\xF3 trong changelog c\u1EE7a g\xF3i \u0111\xE3 c\xE0i (b\u1EA3n v\xE1 backport | |
| lu\xF4n ghi m\xE3 CVE v\xE0o /usr/share/doc/nginx-*/changelog.Debian.gz, \u0111\u1ECDc offline); | |
| '[KH\xD4NG D\xCDNH]' khi module kh\xF4ng \u0111\u01B0\u1EE3c bi\xEAn d\u1ECBch v\xE0o ('nginx -V') ho\u1EB7c c\u1EA5u h\xECnh | |
| \u0111ang ch\u1EA1y kh\xF4ng k\xEDch ho\u1EA1t ph\u1EA7n \u0111\xF3 ('nginx -T': kh\xF4ng mp4, kh\xF4ng HTTP/2, kh\xF4ng | |
| resolver, kh\xF4ng ssl_verify_client); ch\u1EC9 b\xE1o \u0111\u1ED9ng khi KH\xD4NG ch\u1EE9ng minh \u0111\u01B0\u1EE3c l\xE0 | |
| \u0111\xE3 x\u1EED l\xFD, k\xE8m l\xFD do c\xF2n thi\u1EBFu b\u1EB1ng ch\u1EE9ng n\xE0o v\xE0 l\u1EC7nh ki\u1EC3m ch\u1EE9ng th\u1EE7 c\xF4ng. | |
| L\xFD do: Ubuntu/Debian v\xE1 ng\u01B0\u1EE3c m\xE0 gi\u1EEF nguy\xEAn s\u1ED1 upstream, n\xEAn nginx 1.24.0 \u0111\xE3 | |
| v\xE1 v\xE0 ch\u01B0a v\xE1 nh\xECn gi\u1ED1ng h\u1EC7t nhau \u2014 ch\u1EC9 so s\u1ED1 th\xEC b\xE1o \u0111\u1ED9ng m\xE3i kh\xF4ng t\u1EAFt k\u1EC3 | |
| c\u1EA3 sau khi \u0111\xE3 'apt upgrade'. C\xF3 in k\xE8m phi\xEAn b\u1EA3n G\xD3I (vd 1.24.0-2ubuntu7.5). | |
| - 'napp doctor deps [<domain|name>]': qu\xE9t r\u1EE7i ro CHU\u1ED6I CUNG \u1EE8NG (dependency | |
| chain attack) trong m\xE3 ngu\u1ED3n t\u1EEBng app/service \u2014 thi\u1EBFu lockfile, dependency | |
| '*'/'latest', dependency tr\u1ECF th\u1EB3ng git/URL (kh\xF4ng c\xF3 hash to\xE0n v\u1EB9n), t\xEAn g\u1EA7n | |
| gi\u1ED1ng package ph\u1ED5 bi\u1EBFn (typosquat), package ch\u1EA1y script khi c\xE0i | |
| (preinstall/install/postinstall), l\u1ED7 h\u1ED5ng \u0111\xE3 c\xF4ng b\u1ED1 qua audit c\u1EE7a ch\xEDnh | |
| package manager (npm/pnpm/yarn/bun), .npmrc ch\u1EE9a token quy\u1EC1n qu\xE1 r\u1ED9ng. M\u1ED7i | |
| ph\xE1t hi\u1EC7n \u0111\u1EC1u k\xE8m C\xC1CH X\u1EEC L\xDD c\u1EE5 th\u1EC3. Th\xEAm '--deep' \u0111\u1EC3 tra ng\xE0y ph\xE1t h\xE0nh c\u1EE7a | |
| dependency tr\u1EF1c ti\u1EBFp tr\xEAn registry npm (g\xF3i b\u1ECB chi\u1EBFm th\u01B0\u1EDDng ch\u1EC9 s\u1ED1ng v\xE0i gi\u1EDD | |
| t\u1EDBi v\xE0i ng\xE0y tr\u01B0\u1EDBc khi b\u1ECB g\u1EE1). | |
| - Output c\u1EE7a doctor ph\xE2n m\xE0u theo m\u1EE9c \u0111\u1ED9: \u0110\u1ECE \u0110\u1EACM cho NGHI\xCAM TR\u1ECCNG, \u0110\u1ECE cho CAO | |
| (t\xF4 c\u1EA3 n\u1ED9i dung, kh\xF4ng ch\u1EC9 nh\xE3n), v\xE0ng cho TRUNG B\xCCNH, x\xE1m cho ph\u1EA7n tham | |
| kh\u1EA3o. G\xF3i tr\u1ECDng y\u1EBFu trong danh s\xE1ch b\u1EA3n v\xE1 \u0111\u01B0\u1EE3c \u0111\xE1nh d\u1EA5u '!' m\xE0u \u0111\u1ECF. Th\xEAm m\u1EE9c | |
| log '[NGUY HI\u1EC2M]' (\u0111\u1ECF \u0111\u1EADm) cho c\u1EA3nh b\xE1o b\u1EA3o m\u1EADt, t\xE1ch kh\u1ECFi '[C\u1EA2NH B\xC1O]' v\xE0ng | |
| v\u1ED1n d\xF9ng cho vi\u1EC7c v\u1EADn h\xE0nh th\u01B0\u1EDDng. | |
| - 'napp doctor upgrade': L\u1EA4Y B\u1EA2N V\xC1 V\u1EC0 \u2014 m\u1EB7c \u0111\u1ECBnh ch\u1EC9 c\xE0i b\u1EA3n v\xE1 B\u1EA2O M\u1EACT | |
| ('--all' cho m\u1ECDi c\u1EADp nh\u1EADt, '--only nginx' cho m\u1ED9t g\xF3i). D\xF9ng --force-confold | |
| n\xEAn KH\xD4NG ghi \u0111\xE8 c\u1EA5u h\xECnh \u0111ang ch\u1EA1y v\xE0 kh\xF4ng treo \u1EDF prompt c\u1EE7a dpkg; n\xE2ng c\u1EA5p | |
| nginx th\xEC ch\u1EA1y 'nginx -t' TR\u01AF\u1EDAC khi restart (c\u1EA5u h\xECnh sai th\xEC d\u1EEBng, kh\xF4ng l\xE0m | |
| s\u1EADp site); sau khi c\xE0i ch\u1EC9 restart \u0111\xFAng nh\u1EEFng d\u1ECBch v\u1EE5 c\xF2n n\u1EA1p th\u01B0 vi\u1EC7n c\u0169. | |
| ## 1.13.1 | |
| - Background service KH\xD4NG c\xF2n n\u1EB1m \u1EDF /srv/napp n\u1EEFa: m\xE3 ngu\u1ED3n chuy\u1EC3n v\u1EC1 CHUNG | |
| /var/www v\u1EDBi app web \u0111\u1EC3 kh\u1ECFi ph\xE2n m\u1EA3nh th\u01B0 m\u1EE5c v\xE0 kh\u1ECFi \u0111i t\xECm nhi\u1EC1u n\u01A1i. | |
| Ph\xE2n bi\u1EC7t b\u1EB1ng H\u1EACU T\u1ED0 t\xEAn th\u01B0 m\u1EE5c: app web gi\u1EEF t\xEAn domain | |
| (/var/www/api.example.com), service th\xEAm '-service' | |
| (/var/www/queue-email-service). N\u1EB1m trong /var/www KH\xD4NG l\xE0m service public \u2014 | |
| nginx ch\u1EC9 ph\u1EE5c v\u1EE5 th\u01B0 m\u1EE5c n\xE0o c\xF3 vhost tr\u1ECF t\u1EDBi, m\xE0 service th\xEC kh\xF4ng c\xF3 vhost. | |
| - \xC1p d\u1EE5ng cho service T\u1EA0O M\u1EDAI. Service t\u1EA1o b\u1EB1ng b\u1EA3n c\u0169 v\u1EABn ch\u1EA1y \u0111\xFAng th\u01B0 m\u1EE5c c\u0169 | |
| (napp \u0111\u1ECDc \u0111\u01B0\u1EDDng d\u1EABn t\u1EEB registry). Mu\u1ED1n d\u1EDDi sang ch\u1ED7 m\u1EDBi: | |
| sudo systemctl stop napp-svc-<name> | |
| sudo mv /srv/napp/<name> /var/www/<name>-service | |
| sudo sed -i 's#/srv/napp/<name>#/var/www/<name>-service#g' \\ | |
| /etc/napp/state.json /etc/systemd/system/napp-svc-<name>.service | |
| sudo systemctl daemon-reload && sudo systemctl start napp-svc-<name> | |
| - Ch\u1EB7n \u0111\u1EB7t t\xEAn service k\u1EBFt th\xFAc b\u1EB1ng '-service' (napp t\u1EF1 th\xEAm h\u1EADu t\u1ED1 n\xE0y) v\xE0 | |
| ch\u1EB7n t\u1EA1o service tr\xF9ng th\u01B0 m\u1EE5c v\u1EDBi m\u1ED9t app web \u0111ang c\xF3 trong registry. | |
| ## 1.13.0 | |
| - Th\xEAm BACKGROUND SERVICE \u2014 \u1EE9ng d\u1EE5ng Node.js/Bun ch\u1EA1y NG\u1EA6M (worker, bot, queue | |
| consumer, cron poller): KH\xD4NG domain, KH\xD4NG nginx/SSL. Nh\xF3m l\u1EC7nh m\u1EDBi | |
| 'napp service' (create/deploy/remove/list/restart/stop/start/logs/env-set), | |
| c\u0169ng c\xF3 trong menu t\u01B0\u01A1ng t\xE1c. M\u1ED7i service v\u1EABn c\xF3 user h\u1EC7 th\u1ED1ng ri\xEAng, unit | |
| systemd (hardening + t\u1EF1 restart), tu\u1EF3 ch\u1ECDn --db/--redis, v\xE0 clone repo private | |
| qua --token/--ssh-key y nh\u01B0 app web. | |
| - C\u1ED5ng l\xE0 TU\u1EF2 CH\u1ECCN cho service: m\u1EB7c \u0111\u1ECBnh KH\xD4NG c\u1EA5p c\u1ED5ng (worker thu\u1EA7n kh\xF4ng | |
| listen g\xEC). Truy\u1EC1n --port khi service t\u1EF1 bind (health-check/socket) \u2014 v\u1EABn | |
| kh\xF4ng public qua nginx. | |
| - L\u1EC7nh kh\u1EDFi \u0111\u1ED9ng \u0111\u1EB7t t\u1EF1 do qua --start-cmd cho c\u1EA3 framework kh\xE1c nhau (Express: | |
| 'node src/index.js'; SvelteKit adapter-node: 'node build/index.js'; ho\u1EB7c | |
| worker: 'node worker.js'). M\u1EB7c \u0111\u1ECBnh 'npm start' theo package.json. | |
| - Heap V8 nay chia cho T\u1ED4NG s\u1ED1 \u0111\u01A1n v\u1ECB ch\u1EA1y Node (app web + service) \u0111\u1EC3 t\u1ED5ng heap | |
| kh\xF4ng v\u01B0\u1EE3t RAM khi c\xF3 th\xEAm worker. T\u1EF1 c\xE2n \u0111\u1ED1i l\u1EA1i khi t\u1EA1o/xo\xE1 service v\xE0 khi | |
| 'napp tune apply'. Namespace t\xE1ch bi\u1EC7t: service d\xF9ng user 'nas_*', unit | |
| 'napp-svc-*', m\xE3 ngu\u1ED3n \u1EDF /var/www/<name>-service \u2014 kh\xF4ng \u0111\u1EE5ng t\xE0i nguy\xEAn app web. | |
| ## 1.12.2 | |
| - S\u1EEDa l\u1ED7i t\u1EA1o app runtime bun TH\u1EA4T B\u1EA0I khi repo mang lockfile c\u1EE7a tr\xECnh kh\xE1c | |
| (pnpm-lock.yaml / package-lock.json / yarn.lock): bun migrate sang bun.lock | |
| (=thay \u0111\u1ED5i lockfile) r\u1ED3i b\u1ECB ch\u1EB7n "lockfile had changes, but lockfile is | |
| frozen" n\u1EBFu frozen b\u1EADt (bunfig.toml, bi\u1EBFn CI). Nay l\u1EC7nh c\xE0i c\u1EE7a bun \u0111\xE3 | |
| "lockfile-aware": c\xF3 bun.lock -> c\xE0i frozen (t\u1EA5t \u0111\u1ECBnh); kh\xF4ng c\xF3 -> \xE9p | |
| --no-frozen-lockfile \u0111\u1EC3 bun \u0111\u01B0\u1EE3c ph\xE9p ghi lock migrate. C\xF9ng n\u1EBFp v\u1EDBi | |
| pnpm/yarn/npm (\u0111\u1EC1u c\xF3 fallback khi lock l\u1EC7ch). | |
| ## 1.12.1 | |
| - Ph\xE1t h\xE0nh l\u1EA1i (republish) \u2014 kh\xF4ng \u0111\u1ED5i t\xEDnh n\u0103ng, ch\u1EC9 t\u0103ng version \u0111\u1EC3 \u0111\u1EA9y b\u1EA3n | |
| c\u1EADp nh\u1EADt qua 'napp update'. | |
| ## 1.12.0 | |
| - S\u1EEDa l\u1ED7i TREO khi clone repo PRIVATE l\xFAc t\u1EA1o app: tr\u01B0\u1EDBc \u0111\xE2y git/ssh h\u1ECFi | |
| username/password (HTTPS) ho\u1EB7c yes/no host-key (SSH) nh\u01B0ng \u0111\u1ECDc prompt t\u1EEB | |
| terminal \u0111i\u1EC1u khi\u1EC3n \u2014 m\xE0 ti\u1EBFn tr\xECnh ch\u1EA1y s\xE2u qua 'sudo -u <user app>' kh\xF4ng | |
| s\u1EDF h\u1EEFu terminal n\xEAn g\xF5 KH\xD4NG \u0103n, k\u1EB9t v\xF4 h\u1EA1n. Nay M\u1ECCI thao t\xE1c git (clone + | |
| deploy) ch\u1EA1y KH\xD4NG T\u01AF\u01A0NG T\xC1C (GIT_TERMINAL_PROMPT=0, ssh BatchMode=yes, | |
| StrictHostKeyChecking=accept-new): repo private thi\u1EBFu x\xE1c th\u1EF1c s\u1EBD b\xE1o l\u1ED7i | |
| ngay k\xE8m h\u01B0\u1EDBng d\u1EABn, thay v\xEC treo. | |
| - Th\xEAm x\xE1c th\u1EF1c repo private kh\xF4ng t\u01B0\u01A1ng t\xE1c cho 'napp app create': | |
| --token <PAT> clone repo PRIVATE qua HTTPS (l\u01B0u v\xE0o ~/.git-credentials | |
| c\u1EE7a user app, quy\u1EC1n 600; remote gi\u1EEF URL s\u1EA1ch). | |
| --ssh-key <path> clone repo PRIVATE qua SSH b\u1EB1ng deploy key (c\xE0i v\xE0o | |
| ~/.ssh + ~/.ssh/config c\u1EE7a user app, quy\u1EC1n 600). | |
| 'napp app deploy' d\xF9ng l\u1EA1i th\xF4ng tin n\xE0y n\xEAn pull c\xE1c b\u1EA3n sau c\u0169ng kh\xF4ng h\u1ECFi. | |
| Menu t\u01B0\u01A1ng t\xE1c th\xEAm b\u01B0\u1EDBc h\u1ECFi repo c\xF3 private kh\xF4ng r\u1ED3i xin token/deploy key. | |
| ## 1.11.2 | |
| - S\u1EEDa c\u1EA3nh b\xE1o "getcwd: cannot access parent directories" khi t\u1EA1o app: l\u1EC7nh ch\u1EA1y | |
| d\u01B0\u1EDBi user h\u1EC7 th\u1ED1ng c\u1EE7a app k\u1EBF th\u1EEBa CWD c\u1EE7a napp (th\u01B0\u1EDDng /root, user app kh\xF4ng | |
| v\xE0o \u0111\u01B0\u1EE3c). Nay runAs m\u1EB7c \u0111\u1ECBnh cwd="/" n\u1EBFu kh\xF4ng ch\u1EC9 \u0111\u1ECBnh -> h\u1EBFt c\u1EA3nh b\xE1o. App | |
| v\u1EABn t\u1EA1o \u0111\xFAng nh\u01B0 tr\u01B0\u1EDBc; \u0111\xE2y ch\u1EC9 l\xE0 d\u1ECDn ti\u1EBFng \u1ED3n. | |
| ## 1.11.1 | |
| - App m\u1EDBi: th\xEAm kh\u1ED1i G\u1EE2I \xDD (comment) v\u1EC1 CSRF c\u1EE7a SvelteKit v\xE0o .env. adapter-node | |
| ch\u1EB7n POST/form action b\u1EB1ng 403 "Cross-site POST form submissions are forbidden" | |
| khi origin l\u1EC7ch; PROTOCOL_HEADER/HOST_HEADER (\u0111\xE3 c\xF3 s\u1EB5n t\u1EEB 1.10.0) kh\u1EAFc ph\u1EE5c, | |
| k\xE8m d\xF2ng '# ORIGIN=https://<domain>' \u0111\xE3 comment \u0111\u1EC3 b\u1EADt TAY sau khi c\u1EA5p SSL n\u1EBFu | |
| v\u1EABn d\xEDnh 403. Output t\u1EA1o app th\xEAm m\u1ED9t d\xF2ng nh\u1EAFc tr\u1ECF t\u1EDBi ghi ch\xFA n\xE0y. | |
| ## 1.11.0 | |
| - Xo\xE1 app KH\xD4NG c\xF2n m\u1EB7c \u0111\u1ECBnh xo\xE1 s\u1EA1ch m\u1ECDi th\u1EE9: 'napp app remove' gi\u1EDD CH\u1ECCN t\u1EEBng | |
| t\xE0i nguy\xEAn c\u1EA7n xo\xE1 \u2014 c\u1EA5u h\xECnh nginx, ch\u1EE9ng ch\u1EC9 SSL, m\xE3 ngu\u1ED3n (+ user), database. | |
| M\u1EB7c \u0111\u1ECBnh XO\xC1 nginx + ssl, GI\u1EEE m\xE3 ngu\u1ED3n + database (d\u1EEF li\u1EC7u qu\xFD, tr\xE1nh m\u1EA5t tr\u1EAFng). | |
| Menu t\u01B0\u01A1ng t\xE1c hi\u1EC7n danh s\xE1ch [x] tick ch\u1ECDn nhi\u1EC1u m\u1EE5c. C\u1EDD CLI m\u1EDBi: --all, | |
| --source, --db, --keep-nginx, --keep-ssl (--keep-db v\u1EABn nh\u1EADn cho t\u01B0\u01A1ng th\xEDch). | |
| Service systemd LU\xD4N b\u1ECB g\u1EE1 v\xEC app r\u1EDDi kh\u1ECFi registry th\xEC napp kh\xF4ng qu\u1EA3n l\xFD \u0111\u01B0\u1EE3c. | |
| ## 1.10.0 | |
| - S\u1EEDa BUG header WebSocket: vhost \xE9p c\u1EE9ng 'Connection: upgrade' cho M\u1ECCI request, | |
| k\u1EC3 c\u1EA3 HTTP th\u01B0\u1EDDng (Upgrade r\u1ED7ng) -> header m\xE9o + ph\xE1 keepalive t\u1EDBi upstream. | |
| Nay d\xF9ng map \\$napp_connection_upgrade (conf.d/00-napp-proxy.conf): ch\u1EC9 request | |
| WebSocket th\u1EADt m\u1EDBi upgrade. Ch\u1EA1y 'napp nginx sync' \u0111\u1EC3 v\xE1 c\xE1c app \u0110ANG CH\u1EA0Y | |
| (v\xE1 t\u1EA1i ch\u1ED7, KH\xD4NG \u0111\u1EE5ng kh\u1ED1i SSL certbot \u0111\xE3 ch\xE8n). | |
| - App m\u1EDBi t\u1EF1 c\xF3 PROTOCOL_HEADER/HOST_HEADER/ADDRESS_HEADER/XFF_DEPTH trong .env: | |
| SvelteKit adapter-node m\u1EB7c \u0111\u1ECBnh kh\xF4ng tin X-Forwarded-*, n\xEAn app t\u01B0\u1EDFng m\xECnh | |
| ch\u1EA1y HTTP d\xF9 ng\u01B0\u1EDDi d\xF9ng v\xE0o b\u1EB1ng HTTPS -> code redirect "ch\u01B0a https" l\u1EB7p v\xF4 | |
| h\u1EA1n, cookie Secure/CSRF sai. App C\u0168: th\xEAm tay r\u1ED3i 'napp app restart'. | |
| ## 1.9.0 | |
| - Backup: menu t\u1EF1 LI\u1EC6T K\xCA database \u0111\u1EC3 ch\u1ECDn (m\u1ED9t DB c\u1EE5 th\u1EC3 ho\u1EB7c t\u1EA5t c\u1EA3). C\u1EDD m\u1EDBi | |
| 'backup run --database <name>'. File backup v\u1EABn n\xE9n gzip (.sql.gz / .tar.gz). | |
| - Retention theo NG\xC0Y: 'backup run/schedule --keep-days <n>' (m\u1EB7c \u0111\u1ECBnh 14) xo\xE1 | |
| b\u1EA3n c\u0169 h\u01A1n N ng\xE0y; tu\u1EF3 ch\u1ECDn '--keep <n>' gi\u1EDBi h\u1EA1n th\xEAm s\u1ED1 b\u1EA3n g\u1EA7n nh\u1EA5t. | |
| - 'backup list' hi\u1EC3n th\u1ECB k\xEDch th\u01B0\u1EDBc t\u1EEBng file + t\u1ED5ng dung l\u01B0\u1EE3ng. Menu backup | |
| t\xE1ch r\xF5: backup DB / files / t\u1EA5t c\u1EA3 / l\xEAn l\u1ECBch / g\u1EE1 l\u1ECBch / danh s\xE1ch. | |
| ## 1.8.0 | |
| - 'napp nginx harden': t\u1EA1o server M\u1EB6C \u0110\u1ECANH (default_server) tr\u1EA3 444 cho m\u1ECDi | |
| request KH\xD4NG kh\u1EDBp domain \u0111\xE3 c\u1EA5u h\xECnh \u2014 ch\u1EB7n truy c\u1EADp th\u1EB3ng IP, Host gi\u1EA3 m\u1EA1o, | |
| bot qu\xE9t c\u1ED5ng; ch\u1EC9 domain c\xF3 app (server_name kh\u1EDBp) m\u1EDBi v\xE0o \u0111\u01B0\u1EE3c. Ch\u1EB7n c\u1EA3 80 | |
| v\xE0 443 (ssl_reject_handshake tr\xEAn nginx >= 1.19.4, ho\u1EB7c cert t\u1EF1 k\xFD tr\xEAn b\u1EA3n | |
| c\u0169). \u1EA8n phi\xEAn b\u1EA3n nginx (server_tokens off). 'napp nginx unharden' \u0111\u1EC3 g\u1EE1. | |
| C\xF3 s\u1EB5n trong menu H\u1EA1 t\u1EA7ng. | |
| ## 1.7.1 | |
| - 'napp cert issue' ti\u1EC1n ki\u1EC3m DNS: certbot c\u1EA5p M\u1ED8T ch\u1EE9ng ch\u1EC9 cho m\u1ECDi -d, ch\u1EC9 | |
| c\u1EA7n m\u1ED9t domain ch\u01B0a c\xF3 DNS (v\xED d\u1EE5 www ch\u01B0a tr\u1ECF) l\xE0 h\u1ECFng c\u1EA3. Nay napp b\u1ECF c\xE1c | |
| domain ch\u01B0a ph\xE2n gi\u1EA3i (A/AAAA) k\xE8m c\u1EA3nh b\xE1o, \u0111\u1EC3 ph\u1EA7n c\xF2n l\u1EA1i v\u1EABn c\u1EA5p \u0111\u01B0\u1EE3c; | |
| n\u1EBFu domain CH\xCDNH ch\u01B0a ph\xE2n gi\u1EA3i th\xEC b\xE1o l\u1ED7i r\xF5 r\xE0ng. | |
| ## 1.7.0 | |
| - S\u1EEDa 'napp cert issue' b\u1ECB TREO \u1EDF prompt nh\u1EADp email c\u1EE7a certbot: nay ch\u1EA1y | |
| --non-interactive --agree-tos --email (nh\u1EDB email trong state cho l\u1EA7n sau) v\xE0 | |
| --redirect (t\u1EF1 th\xEAm chuy\u1EC3n HTTP->HTTPS). C\u1EDD m\u1EDBi: --email, --register-without-email, | |
| --no-redirect. Thi\u1EBFu email th\xEC b\xE1o l\u1ED7i r\xF5 r\xE0ng thay v\xEC treo. | |
| - Menu SSL: ph\xE1t h\xE0nh / gia h\u1EA1n / thu h\u1ED3i gi\u1EDD CH\u1ECCN domain t\u1EEB danh s\xE1ch app; th\xEAm | |
| m\u1EE5c 'Thu h\u1ED3i / g\u1EE1 ch\u1EE9ng ch\u1EC9' v\xE0 'Gia h\u1EA1n m\u1ED9t domain'. | |
| ## 1.6.1 | |
| - S\u1EEDa l\u1ED7i t\u1EA1o app th\u1EA5t b\u1EA1i + rollback khi ch\u1ECDn pnpm/yarn ch\u01B0a c\xE0i (b\xE1o | |
| 'command not found' d\u01B0\u1EDBi app user). Nay napp ki\u1EC3m tra pm c\xF3 \u1EDF M\u1EE8C H\u1EC6 TH\u1ED0NG | |
| (/usr, /opt) kh\xF4ng \u2014 n\u1EBFu ch\u01B0a, t\u1EF1 'npm install -g pnpm|yarn' \u0111\u1EC3 app user v\xE0 | |
| systemd \u0111\u1EC1u d\xF9ng \u0111\u01B0\u1EE3c, v\xE0 fail S\u1EDAM (tr\u01B0\u1EDBc khi t\u1EA1o t\xE0i nguy\xEAn) n\u1EBFu bun thi\u1EBFu. | |
| Deploy c\u0169ng t\u1EF1 \u0111\u1EA3m b\u1EA3o pm tr\u01B0\u1EDBc khi c\xE0i deps. | |
| ## 1.6.0 | |
| - Heap V8 (--max-old-space-size) gi\u1EDD CHIA THEO S\u1ED0 APP: ng\xE2n s\xE1ch RAM cho app | |
| (RAM \u2212 MariaDB/Redis/OS) chia \u0111\u1EC1u cho s\u1ED1 app, \u0111\u1EC3 t\u1ED5ng heap v\u1EEBa v\u1EDBi RAM (quan | |
| tr\u1ECDng tr\xEAn m\xE1y 1GB ch\u1EA1y nhi\u1EC1u app). T\u1EF1 c\xE2n \u0111\u1ED1i l\u1EA1i khi T\u1EA0O/XO\xC1 app (ghi l\u1EA1i | |
| unit + restart c\xE1c app kh\xE1c) v\xE0 khi 'napp tune apply'. V\xED d\u1EE5 1GB: 1 app=384MB, | |
| 2 app=230MB, 3 app=153MB m\u1ED7i app. | |
| ## 1.5.0 | |
| - NODE_OPTIONS (--max-old-space-size) t\u1EF1 t\xEDnh theo RAM/tier cho app runtime=node, | |
| \u0111\u1EB7t trong unit systemd (user override \u0111\u01B0\u1EE3c qua .env). bun kh\xF4ng set (d\xF9ng JSC). | |
| - systemd unit \u0111\u1ED5i ProtectHome=yes -> tmpfs: v\u1EABn gi\u1EA5u home th\u1EADt nh\u01B0ng c\u1EA5p $HOME | |
| r\u1ED7ng ghi \u0111\u01B0\u1EE3c, th\xE2n thi\u1EC7n runtime (bun/node) h\u01A1n. | |
| - 'napp tune apply' gi\u1EDD c\u0169ng ghi l\u1EA1i unit m\u1ECDi app (\xE1p NODE_OPTIONS + hardening | |
| m\u1EDBi) v\xE0 kh\u1EDFi \u0111\u1ED9ng l\u1EA1i; 'napp tune show' hi\u1EC3n th\u1ECB heap d\u1EF1 ki\u1EBFn. | |
| - Redis maxmemory-policy: allkeys-lru -> volatile-lru (an to\xE0n h\u01A1n khi nhi\u1EC1u app | |
| d\xF9ng chung Redis \u2014 ch\u1EC9 tr\u1EE5c xu\u1EA5t key c\xF3 TTL). | |
| ## 1.4.0 | |
| - Banner gi\u1EDBi thi\u1EC7u napp (ASCII, c\xF3 m\xE0u, k\xE8m phi\xEAn b\u1EA3n \u0111\u1ED9ng + g\u1EE3i \xFD l\u1EC7nh) hi\u1EC3n | |
| th\u1ECB m\u1ED7i khi \u0111\u0103ng nh\u1EADp SSH. C\xE0i b\u1EDFi 'napp install', g\u1EE1 b\u1EDFi 'napp uninstall'. | |
| 'napp update' t\u1EF1 l\xE0m m\u1EDBi banner n\u1EBFu \u0111ang b\u1EADt. | |
| ## 1.3.0 | |
| - Th\xEAm systemd timer t\u1EF1 \u0111\u1ED9ng \u0111\u1ED3ng b\u1ED9 IP Cloudflare v\xE0o nginx (real-IP): | |
| \`napp cloudflare schedule [--time HH:MM]\` (m\u1EB7c \u0111\u1ECBnh 01:00 h\xE0ng ng\xE0y) v\xE0 | |
| \`napp cloudflare unschedule\`. C\u0169ng c\xF3 trong menu H\u1EA1 t\u1EA7ng. | |
| ## 1.2.0 | |
| - T\u01B0\u1EDDng l\u1EEDa UFW KH\xD4NG c\xF2n gi\u1EDBi h\u1EA1n 80/443 ch\u1EC9 cho d\u1EA3i IP Cloudflare theo m\u1EB7c | |
| \u0111\u1ECBnh \u2014 nay m\u1EDF 80/443 c\xF4ng khai. Vi\u1EC7c l\u1EA5y IP client th\u1EADt l\xE0 c\u1EE7a nginx real-IP | |
| (\`napp cloudflare sync\`), KH\xD4NG li\xEAn quan t\u1EDBi UFW. Mu\u1ED1n kho\xE1 origin theo IP | |
| Cloudflare (n\xE2ng cao) th\xEC th\xEAm c\u1EDD \`--restrict-cloudflare\`. | |
| - Timer \u0111\u1ED3ng b\u1ED9 Cloudflare ch\u1EC9 c\xF2n refresh nginx real-IP, kh\xF4ng \u0111\u1EE5ng UFW n\u1EEFa. | |
| ## 1.1.0 | |
| - Ch\u1ECDn TR\xCCNH QU\u1EA2N L\xDD G\xD3I (npm/pnpm/yarn/bun) khi t\u1EA1o app, t\xE1ch b\u1EA1ch kh\u1ECFi | |
| runtime engine (node/bun). C\u1EDD m\u1EDBi: --package-manager. L\u1EC7nh c\xE0i m\u1EB7c \u0111\u1ECBnh | |
| c\u1EE7a m\u1ECDi PM \u0111\u1EC1u "lockfile-aware" (ch\u1EC9 c\xE0i theo lock khi c\xF3 lockfile). | |
| - Menu t\u01B0\u01A1ng t\xE1c: deploy/restart/xem-log/xo\xE1 gi\u1EDD X\u1ED4 DANH S\xC1CH app \u0111\u1EC3 ch\u1ECDn | |
| theo s\u1ED1 th\u1EE9 t\u1EF1, kh\xF4ng c\u1EA7n g\xF5 tay domain n\u1EEFa. | |
| - S\u1EEDa: 'npm ci' ch\u1EC9 ch\u1EA1y khi c\xF3 package-lock.json (app m\u1EABu/repo kh\xF4ng lock | |
| kh\xF4ng c\xF2n phun l\u1ED7i EUSAGE). | |
| - Wire ngu\u1ED3n t\u1EF1 c\u1EADp nh\u1EADt (napp update) t\u1EDBi gist ch\xEDnh th\u1EE9c. | |
| ## 1.0.0 | |
| - Ph\xE1t h\xE0nh \u0111\u1EA7u ti\xEAn: qu\u1EA3n l\xFD app Node.js/Bun \u0111a ng\u01B0\u1EDDi d\xF9ng, domain, SSL | |
| (certbot), MariaDB, Redis, systemd service/timer, nginx + Cloudflare real | |
| IP, fail2ban, UFW, backup \u0111\u1ECBnh k\u1EF3, t\u1ED1i \u01B0u theo ph\u1EA7n c\u1EE9ng, t\u1EF1 c\u1EADp nh\u1EADt OTA | |
| qua gist. | |
| `; | |
| // src/commands/check.ts | |
| var import_node_fs15 = require("node:fs"); | |
| // src/templates/tuning.ts | |
| var MARIADB_TUNING_PATH = "/etc/mysql/conf.d/napp-tuning.cnf"; | |
| var REDIS_TUNING_PATH = "/etc/redis/conf.d/napp-tuning.conf"; | |
| var SYSCTL_TUNING_PATH = "/etc/sysctl.d/99-napp-tuning.conf"; | |
| var DB_RAM_PERCENT = { | |
| micro: 25, | |
| // máy rất nhỏ: ưu tiên OS + 1-2 app node sống sót trước | |
| small: 35, | |
| medium: 40, | |
| large: 45, | |
| xlarge: 50 | |
| }; | |
| var REDIS_RAM_PERCENT = { | |
| micro: 5, | |
| small: 8, | |
| medium: 10, | |
| large: 12, | |
| xlarge: 15 | |
| }; | |
| var NODE_OS_RESERVE_PERCENT = { | |
| micro: 25, | |
| // máy 1GB: chừa nhiều cho kernel/OS | |
| small: 20, | |
| medium: 18, | |
| large: 15, | |
| xlarge: 12 | |
| }; | |
| var NODE_HEAP_CAP_MB = { | |
| micro: 384, | |
| small: 768, | |
| medium: 1280, | |
| large: 2048, | |
| xlarge: 3072 | |
| }; | |
| var NODE_HEAP_FLOOR_MB = 128; | |
| function mb(n) { | |
| return `${Math.max(16, Math.round(n))}M`; | |
| } | |
| var CPU_WEIGHT_WEB = 200; | |
| var CPU_WEIGHT_SERVICE = 50; | |
| var IO_WEIGHT_WEB = 200; | |
| var IO_WEIGHT_SERVICE = 50; | |
| var SERVICE_WEIGHT_DEFAULT = 0.5; | |
| function serviceMemoryHighMB(serviceHeapMB) { | |
| return Math.max(256, Math.round(serviceHeapMB * 3)); | |
| } | |
| function nodeHeapPlan(hw, mix, opts = {}) { | |
| const serviceWeight = Math.min(1, Math.max(0.1, opts.serviceWeight ?? SERVICE_WEIGHT_DEFAULT)); | |
| const dbPercent = opts.dbRamPercent ?? DB_RAM_PERCENT[hw.tier]; | |
| const budgetPercent = Math.max(15, 100 - dbPercent - REDIS_RAM_PERCENT[hw.tier] - NODE_OS_RESERVE_PERCENT[hw.tier]); | |
| const budgetMB = hw.totalMemMB * budgetPercent / 100; | |
| const denominator = mix.webApps + mix.services * serviceWeight; | |
| const perWeb = denominator > 0 ? budgetMB / denominator : budgetMB; | |
| const cap = NODE_HEAP_CAP_MB[hw.tier]; | |
| const clamp = (v) => Math.max(NODE_HEAP_FLOOR_MB, Math.min(cap, Math.floor(v))); | |
| return { | |
| webMB: clamp(perWeb), | |
| serviceMB: clamp(perWeb * serviceWeight), | |
| serviceWeight, | |
| totalUnits: mix.webApps + mix.services | |
| }; | |
| } | |
| function computeTuningPlan(hw, dbRamPercentOverride, mix = { webApps: 1, services: 0 }, serviceWeight) { | |
| const dbPercent = dbRamPercentOverride ?? DB_RAM_PERCENT[hw.tier]; | |
| const redisPercent = REDIS_RAM_PERCENT[hw.tier]; | |
| const innodbBufferPoolMB = Math.round(hw.totalMemMB * dbPercent / 100); | |
| const redisMaxMemoryMB = Math.round(hw.totalMemMB * redisPercent / 100); | |
| const maxConnections = hw.tier === "micro" ? 50 : hw.tier === "small" ? 100 : hw.tier === "medium" ? 150 : hw.tier === "large" ? 250 : 400; | |
| const tmpTableMB = hw.tier === "micro" ? 16 : hw.tier === "small" ? 32 : 64; | |
| const tableOpenCache = hw.tier === "micro" ? 200 : hw.tier === "small" ? 400 : 800; | |
| const workerConnections = hw.tier === "micro" ? 1024 : hw.tier === "small" ? 2048 : 4096; | |
| return { | |
| innodbBufferPoolMB, | |
| maxConnections, | |
| tmpTableMB, | |
| tableOpenCache, | |
| redisMaxMemoryMB, | |
| workerConnections, | |
| heap: nodeHeapPlan(hw, mix, { dbRamPercent: dbRamPercentOverride, serviceWeight }) | |
| }; | |
| } | |
| function renderMariadbTuning(hw, plan) { | |
| return `# Managed by napp \u2014 T\u1EF0 \u0110\u1ED8NG SINH RA b\u1EDFi \`napp tune apply\` | |
| # Ph\u1EA7n c\u1EE9ng ph\xE1t hi\u1EC7n: ${hw.cpuCores} l\xF5i CPU, ${(hw.totalMemMB / 1024).toFixed(1)} GB RAM, tier=${hw.tier} | |
| # T\u1EF7 l\u1EC7 RAM d\xE0nh cho InnoDB buffer pool \u0111\u01B0\u1EE3c t\xEDnh TO\xC1N TH\u1EACN TR\u1ECCNG v\xEC server | |
| # c\xF2n ch\u1EA1y song song Node.js apps + Redis + nginx. | |
| [mysqld] | |
| innodb_buffer_pool_size = ${mb(plan.innodbBufferPoolMB)} | |
| innodb_buffer_pool_instances = ${Math.max(1, Math.min(8, Math.floor(plan.innodbBufferPoolMB / 1024) || 1))} | |
| innodb_log_file_size = ${mb(Math.max(64, plan.innodbBufferPoolMB * 0.25))} | |
| innodb_flush_log_at_trx_commit = 2 | |
| innodb_flush_method = O_DIRECT | |
| innodb_io_capacity = ${hw.tier === "micro" ? 100 : hw.tier === "small" ? 200 : 400} | |
| max_connections = ${plan.maxConnections} | |
| wait_timeout = 300 | |
| interactive_timeout = 300 | |
| tmp_table_size = ${mb(plan.tmpTableMB)} | |
| max_heap_table_size = ${mb(plan.tmpTableMB)} | |
| table_open_cache = ${plan.tableOpenCache} | |
| table_definition_cache = ${plan.tableOpenCache} | |
| thread_cache_size = ${Math.max(8, hw.cpuCores * 4)} | |
| slow_query_log = 1 | |
| slow_query_log_file = /var/log/mysql/slow.log | |
| long_query_time = 2 | |
| `; | |
| } | |
| function renderRedisTuning(hw, plan) { | |
| return `# Managed by napp \u2014 T\u1EF0 \u0110\u1ED8NG SINH RA b\u1EDFi \`napp tune apply\` | |
| # Ph\u1EA7n c\u1EE9ng ph\xE1t hi\u1EC7n: ${(hw.totalMemMB / 1024).toFixed(1)} GB RAM, tier=${hw.tier} | |
| maxmemory ${plan.redisMaxMemoryMB}mb | |
| # noeviction \u2014 B\u1EAET BU\u1ED8C khi c\xF3 app d\xF9ng BullMQ (ho\u1EB7c h\xE0ng \u0111\u1EE3i Redis n\xF3i chung). | |
| # BullMQ t\u1EF1 ki\u1EC3m tra l\xFAc k\u1EBFt n\u1ED1i v\xE0 c\u1EA3nh b\xE1o: "IMPORTANT! Eviction policy is | |
| # volatile-lru. It should be noeviction". | |
| # | |
| # L\xFD do: d\u1EEF li\u1EC7u h\xE0ng \u0111\u1EE3i KH\xD4NG ph\u1EA3i cache \u2014 \u0111\xF3 l\xE0 job \u0111ang ch\u1EDD/\u0111ang ch\u1EA1y, kho\xE1, | |
| # k\u1EBFt qu\u1EA3, th\u1EE9 ch\u1EC9 t\u1ED3n t\u1EA1i m\u1ED9t b\u1EA3n duy nh\u1EA5t. V\u1EDBi m\u1ECDi ch\xEDnh s\xE1ch lru/lfu/random, | |
| # khi ch\u1EA1m maxmemory Redis s\u1EBD T\u1EF0 TR\u1EE4C XU\u1EA4T key \u0111\u1EC3 nh\u01B0\u1EDDng ch\u1ED7: job b\u1ED1c h\u01A1i gi\u1EEFa | |
| # ch\u1EEBng, KH\xD4NG b\xEAn n\xE0o b\xE1o l\u1ED7i (BullMQ ch\u1EC9 th\u1EA5y job "kh\xF4ng c\xF2n t\u1ED3n t\u1EA1i"). K\u1EC3 c\u1EA3 | |
| # volatile-lru c\u0169ng kh\xF4ng an to\xE0n: BullMQ c\xF3 \u0111\u1EB7t TTL cho m\u1ED9t s\u1ED1 key (job \u0111\xE3 | |
| # xong, kho\xE1, rate-limit), n\xEAn "ch\u1EC9 tr\u1EE5c xu\u1EA5t key c\xF3 TTL" v\u1EABn \u0103n \u0111\xFAng v\xE0o d\u1EEF | |
| # li\u1EC7u c\u1EE7a h\xE0ng \u0111\u1EE3i. V\u1EDBi noeviction, Redis T\u1EEA CH\u1ED0I l\u1EC7nh ghi (b\xE1o OOM) thay v\xEC \xE2m | |
| # th\u1EA7m xo\xE1 \u2014 h\u1ECFng l\u1ED9 li\u1EC5u c\xF2n h\u01A1n m\u1EA5t vi\u1EC7c trong im l\u1EB7ng. | |
| # | |
| # napp d\xF9ng CHUNG m\u1ED9t Redis cho nhi\u1EC1u app (m\u1ED7i app m\u1ED9t DB index), n\xEAn ch\u1EC9 c\u1EA7n | |
| # M\u1ED8T app d\xF9ng queue l\xE0 c\u1EA3 instance ph\u1EA3i noeviction \u2014 ch\xEDnh s\xE1ch n\xE0y \xE1p cho to\xE0n | |
| # server, kh\xF4ng t\xE1ch theo DB index \u0111\u01B0\u1EE3c. | |
| # | |
| # \u0110\xE1nh \u0111\u1ED5i: khi Redis \u0111\u1EA7y, l\u1EC7nh ghi s\u1EBD l\u1ED7i OOM ch\u1EE9 kh\xF4ng t\u1EF1 d\u1ECDn d\u1EB9p. H\xE3y \u0110\u1EB6T TTL | |
| # cho key cache c\u1EE7a app (Redis v\u1EABn xo\xE1 key h\u1EBFt h\u1EA1n b\xECnh th\u01B0\u1EDDng \u2014 noeviction ch\u1EC9 | |
| # t\u1EAFt vi\u1EC7c tr\u1EE5c xu\u1EA5t key CH\u01AFA h\u1EBFt h\u1EA1n) v\xE0 theo d\xF5i 'napp redis info' | |
| # (used_memory so v\u1EDBi maxmemory). | |
| maxmemory-policy noeviction | |
| # B\u1EC1n v\u1EEFng nh\u1EB9 (AOF everysec) \u2014 c\xE2n b\u1EB1ng gi\u1EEFa an to\xE0n d\u1EEF li\u1EC7u (session/cache | |
| # c\u1EE7a c\xE1c app node) v\xE0 hi\u1EC7u n\u0103ng. N\u1EBFu Redis ch\u1EC9 d\xF9ng l\xE0m cache thu\u1EA7n tu\xFD, c\xF3 | |
| # th\u1EC3 t\u1EAFt appendonly \u0111\u1EC3 gi\u1EA3m I/O. | |
| appendonly yes | |
| appendfsync everysec | |
| auto-aof-rewrite-percentage 100 | |
| auto-aof-rewrite-min-size 64mb | |
| # Redis l\xE0 \u0111\u01A1n lu\u1ED3ng cho ph\u1EA7n x\u1EED l\xFD l\u1EC7nh \u2014 h\u1EA1n ch\u1EBF client ch\u1EADm chi\u1EBFm gi\u1EEF. | |
| timeout 300 | |
| tcp-keepalive 300 | |
| `; | |
| } | |
| function renderSysctlTuning() { | |
| return `# Managed by napp \u2014 T\u1EF0 \u0110\u1ED8NG SINH RA b\u1EDFi \`napp tune apply\` | |
| # ---- Network ---- | |
| net.core.somaxconn = 65535 | |
| net.core.netdev_max_backlog = 65535 | |
| net.ipv4.tcp_keepalive_time = 600 | |
| net.ipv4.tcp_keepalive_intvl = 60 | |
| net.ipv4.tcp_keepalive_probes = 5 | |
| net.ipv4.tcp_fastopen = 3 | |
| net.ipv4.tcp_tw_reuse = 1 | |
| net.ipv4.ip_local_port_range = 10000 65535 | |
| fs.file-max = 2097152 | |
| fs.nr_open = 2097152 | |
| # ---- Security ---- | |
| net.ipv4.icmp_echo_ignore_broadcasts = 1 | |
| net.ipv4.icmp_ignore_bogus_error_responses = 1 | |
| net.ipv4.tcp_syncookies = 1 | |
| net.ipv4.tcp_max_syn_backlog = 65535 | |
| net.ipv4.conf.all.accept_source_route = 0 | |
| net.ipv4.conf.default.accept_source_route = 0 | |
| net.ipv4.conf.all.rp_filter = 1 | |
| net.ipv4.conf.default.rp_filter = 1 | |
| net.ipv4.conf.all.accept_redirects = 0 | |
| net.ipv4.conf.default.accept_redirects = 0 | |
| net.ipv4.conf.all.send_redirects = 0 | |
| net.ipv6.conf.all.accept_redirects = 0 | |
| net.ipv6.conf.default.accept_redirects = 0 | |
| # ---- Memory ---- | |
| vm.swappiness = 10 | |
| vm.vfs_cache_pressure = 50 | |
| vm.dirty_ratio = 15 | |
| vm.dirty_background_ratio = 5 | |
| `; | |
| } | |
| // src/lib/state.ts | |
| var import_node_fs2 = require("node:fs"); | |
| var NAPP_ROOT = "/etc/napp"; | |
| var STATE_PATH = `${NAPP_ROOT}/state.json`; | |
| var WWW_ROOT = "/var/www"; | |
| var NGINX_AVAILABLE = "/etc/nginx/sites-available"; | |
| var NGINX_ENABLED = "/etc/nginx/sites-enabled"; | |
| var SYSTEMD_DIR = "/etc/systemd/system"; | |
| var USER_PREFIX = "na_"; | |
| var SERVICE_DIR_SUFFIX = "-service"; | |
| var SERVICE_USER_PREFIX = "nas_"; | |
| var BACKUP_ROOT = "/var/backups/napp"; | |
| var PORT_RANGE_START = 3e3; | |
| var PORT_RANGE_END = 3999; | |
| var REDIS_DB_MAX = 16; | |
| function emptyState() { | |
| return { version: 1, apps: {}, services: {}, usedPorts: [], usedRedisDb: [] }; | |
| } | |
| var cache = null; | |
| function loadState() { | |
| if (cache) return cache; | |
| if (!(0, import_node_fs2.existsSync)(STATE_PATH)) { | |
| cache = emptyState(); | |
| return cache; | |
| } | |
| try { | |
| const raw = (0, import_node_fs2.readFileSync)(STATE_PATH, "utf8"); | |
| cache = JSON.parse(raw); | |
| cache.apps ??= {}; | |
| cache.services ??= {}; | |
| cache.usedPorts ??= []; | |
| cache.usedRedisDb ??= []; | |
| return cache; | |
| } catch (e) { | |
| die(`Kh\xF4ng \u0111\u1ECDc \u0111\u01B0\u1EE3c ${STATE_PATH} (file registry b\u1ECB h\u1ECFng?): ${e.message}`); | |
| } | |
| } | |
| function saveState(s) { | |
| cache = s; | |
| ensureDir(NAPP_ROOT, 488); | |
| writeFile(STATE_PATH, JSON.stringify(s, null, 2) + "\n", 416); | |
| if (state.dryRun) cache = null; | |
| } | |
| function getAcmeEmail() { | |
| return loadState().acmeEmail; | |
| } | |
| function setAcmeEmail(email) { | |
| const s = loadState(); | |
| s.acmeEmail = email; | |
| saveState(s); | |
| } | |
| function getApp(domain2) { | |
| return loadState().apps[domain2]; | |
| } | |
| function requireApp(domain2) { | |
| const app2 = getApp(domain2); | |
| if (!app2) { | |
| die( | |
| `Kh\xF4ng t\xECm th\u1EA5y app cho domain '${domain2}' trong registry (${STATE_PATH}). | |
| Ch\u1EA1y 'napp app list' \u0111\u1EC3 xem danh s\xE1ch, ho\u1EB7c 'napp app create ${domain2} ...' \u0111\u1EC3 t\u1EA1o m\u1EDBi.` | |
| ); | |
| } | |
| return app2; | |
| } | |
| function upsertApp(app2) { | |
| const s = loadState(); | |
| s.apps[app2.domain] = app2; | |
| if (!s.usedPorts.includes(app2.port)) s.usedPorts.push(app2.port); | |
| if (app2.redisDbIndex !== void 0 && !s.usedRedisDb.includes(app2.redisDbIndex)) { | |
| s.usedRedisDb.push(app2.redisDbIndex); | |
| } | |
| saveState(s); | |
| } | |
| function releaseRedisDbIfUnused(s, index) { | |
| if (index === void 0) return; | |
| const stillUsed = Object.values(s.apps).some((a) => a.redisDbIndex === index) || Object.values(s.services).some((v) => v.redisDbIndex === index); | |
| if (!stillUsed) s.usedRedisDb = s.usedRedisDb.filter((d) => d !== index); | |
| } | |
| function removeApp(domain2) { | |
| const s = loadState(); | |
| const app2 = s.apps[domain2]; | |
| if (!app2) return void 0; | |
| delete s.apps[domain2]; | |
| s.usedPorts = s.usedPorts.filter((p) => p !== app2.port); | |
| releaseRedisDbIfUnused(s, app2.redisDbIndex); | |
| saveState(s); | |
| return app2; | |
| } | |
| function allocatePort(preferred) { | |
| const s = loadState(); | |
| if (preferred !== void 0) { | |
| if (s.usedPorts.includes(preferred)) { | |
| die(`C\u1ED5ng ${preferred} \u0111\xE3 \u0111\u01B0\u1EE3c app kh\xE1c s\u1EED d\u1EE5ng. H\xE3y ch\u1ECDn c\u1ED5ng kh\xE1c ho\u1EB7c b\u1ECF tr\u1ED1ng --port \u0111\u1EC3 t\u1EF1 \u0111\u1ED9ng c\u1EA5p ph\xE1t.`); | |
| } | |
| return preferred; | |
| } | |
| for (let p = PORT_RANGE_START; p <= PORT_RANGE_END; p++) { | |
| if (!s.usedPorts.includes(p)) return p; | |
| } | |
| die(`\u0110\xE3 h\u1EBFt c\u1ED5ng tr\u1ED1ng trong d\u1EA3i ${PORT_RANGE_START}-${PORT_RANGE_END}. H\xE3y ch\u1EC9 \u0111\u1ECBnh --port th\u1EE7 c\xF4ng ngo\xE0i d\u1EA3i n\xE0y.`); | |
| } | |
| function allocateRedisDb() { | |
| const s = loadState(); | |
| for (let i = 1; i < REDIS_DB_MAX; i++) { | |
| if (!s.usedRedisDb.includes(i)) return i; | |
| } | |
| return void 0; | |
| } | |
| function resolveRedisDb(preferred) { | |
| if (preferred === void 0) return allocateRedisDb(); | |
| if (!Number.isInteger(preferred) || preferred < 0 || preferred >= REDIS_DB_MAX) { | |
| die(`--redis-db kh\xF4ng h\u1EE3p l\u1EC7: ${preferred} (h\u1EE3p l\u1EC7: 0-${REDIS_DB_MAX - 1})`); | |
| } | |
| return preferred; | |
| } | |
| function redisDbOf(identifier) { | |
| const s = loadState(); | |
| const unit = s.apps[identifier] ?? s.services[identifier]; | |
| if (!unit) { | |
| die( | |
| `--share-redis-with: kh\xF4ng t\xECm th\u1EA5y app/service '${identifier}' trong registry (${STATE_PATH}). | |
| Xem danh s\xE1ch: napp app list \xB7 napp service list` | |
| ); | |
| } | |
| if (unit.redisDbIndex === void 0) { | |
| die( | |
| `--share-redis-with: '${identifier}' kh\xF4ng \u0111\u01B0\u1EE3c c\u1EA5p Redis DB n\xE0o n\xEAn kh\xF4ng c\xF3 g\xEC \u0111\u1EC3 d\xF9ng chung. | |
| H\xE3y t\u1EA1o n\xF3 v\u1EDBi --redis, ho\u1EB7c ch\u1EC9 \u0111\u1ECBnh th\u1EB3ng --redis-db <n>.` | |
| ); | |
| } | |
| return unit.redisDbIndex; | |
| } | |
| function findUnit(identifier) { | |
| const s = loadState(); | |
| const app2 = s.apps[identifier]; | |
| if (app2) return { kind: "app", id: app2.domain, user: app2.user, root: app2.webRoot, redisDbIndex: app2.redisDbIndex }; | |
| const svc = s.services[identifier]; | |
| if (svc) return { kind: "service", id: svc.name, user: svc.user, root: svc.workDir, redisDbIndex: svc.redisDbIndex }; | |
| return void 0; | |
| } | |
| function servicesRunningAs(identifier, user) { | |
| return Object.values(loadState().services).filter((s) => s.runAsUnit === identifier || user !== void 0 && s.runAsUnit !== void 0 && s.user === user); | |
| } | |
| function slugFor(domain2) { | |
| const slug = domain2.toLowerCase().replace(/[.-]/g, "_").replace(/[^a-z0-9_]/g, ""); | |
| return slug.slice(0, 24); | |
| } | |
| function userFor(domain2) { | |
| return (USER_PREFIX + slugFor(domain2)).slice(0, 32); | |
| } | |
| function serviceNameFor(domain2) { | |
| return `napp-${slugFor(domain2)}`; | |
| } | |
| function serviceUserFor(name) { | |
| return (SERVICE_USER_PREFIX + slugFor(name)).slice(0, 32); | |
| } | |
| function svcSystemdName(name) { | |
| return `napp-svc-${slugFor(name)}`; | |
| } | |
| function serviceWorkDirFor(name) { | |
| return `${WWW_ROOT}/${name}${SERVICE_DIR_SUFFIX}`; | |
| } | |
| function getService(name) { | |
| return loadState().services[name]; | |
| } | |
| function requireService(name) { | |
| const svc = getService(name); | |
| if (!svc) { | |
| die( | |
| `Kh\xF4ng t\xECm th\u1EA5y background service '${name}' trong registry (${STATE_PATH}). | |
| Ch\u1EA1y 'napp service list' \u0111\u1EC3 xem danh s\xE1ch, ho\u1EB7c 'napp service create ${name} ...' \u0111\u1EC3 t\u1EA1o m\u1EDBi.` | |
| ); | |
| } | |
| return svc; | |
| } | |
| function upsertService(svc) { | |
| const s = loadState(); | |
| s.services[svc.name] = svc; | |
| if (svc.port !== void 0 && !s.usedPorts.includes(svc.port)) s.usedPorts.push(svc.port); | |
| if (svc.redisDbIndex !== void 0 && !s.usedRedisDb.includes(svc.redisDbIndex)) { | |
| s.usedRedisDb.push(svc.redisDbIndex); | |
| } | |
| saveState(s); | |
| } | |
| function removeService(name) { | |
| const s = loadState(); | |
| const svc = s.services[name]; | |
| if (!svc) return void 0; | |
| delete s.services[name]; | |
| if (svc.port !== void 0) s.usedPorts = s.usedPorts.filter((p) => p !== svc.port); | |
| releaseRedisDbIfUnused(s, svc.redisDbIndex); | |
| saveState(s); | |
| return svc; | |
| } | |
| // src/commands/nginx.ts | |
| var import_node_fs5 = require("node:fs"); | |
| // src/lib/network.ts | |
| var import_node_fs3 = require("node:fs"); | |
| function ipv6Available() { | |
| return (0, import_node_fs3.existsSync)("/proc/net/if_inet6"); | |
| } | |
| // src/lib/locationsfile.ts | |
| var import_node_fs4 = require("node:fs"); | |
| // src/templates/nginx.ts | |
| var CLOUDFLARE_REALIP_CONF = "/etc/nginx/conf.d/cloudflare-realip.conf"; | |
| var NGINX_TUNING_CONF = "/etc/nginx/conf.d/napp-tuning.conf"; | |
| var NGINX_DEFAULT_SERVER_CONF = "/etc/nginx/conf.d/00-napp-default-server.conf"; | |
| var NGINX_HARDENING_CONF = "/etc/nginx/conf.d/napp-hardening.conf"; | |
| var NGINX_PROXY_CONF = "/etc/nginx/conf.d/00-napp-proxy.conf"; | |
| var NGINX_LOCATIONS_DIR = "/etc/nginx/napp-locations"; | |
| function appLocationsPath(domain2) { | |
| return `${NGINX_LOCATIONS_DIR}/${domain2}.conf`; | |
| } | |
| function appCustomLocationsPath(domain2) { | |
| return `${NGINX_LOCATIONS_DIR}/${domain2}.custom.conf`; | |
| } | |
| var NGINX_SCANNER_BLOCK_CONF = `${NGINX_LOCATIONS_DIR}/_scanner-block.conf`; | |
| var NGINX_SCANNER_LOG = "/var/log/nginx/napp-scanner.log"; | |
| var NGINX_SCANNER_LOG_FORMAT = "napp_scan"; | |
| function renderNappProxyConf() { | |
| return `# Managed by napp \u2014 T\u1EF0 \u0110\u1ED8NG SINH RA, \u0111\u1EEBng s\u1EEDa tay (ch\u1EA1y \`napp nginx sync\` \u0111\u1EC3 c\u1EADp nh\u1EADt). | |
| # Quy\u1EBFt \u0111\u1ECBnh gi\xE1 tr\u1ECB header 'Connection' g\u1EEDi l\xEAn upstream: | |
| # - Request WebSocket (c\xF3 Upgrade: websocket) -> 'Connection: upgrade' | |
| # - Request HTTP th\u01B0\u1EDDng (Upgrade r\u1ED7ng) -> Connection R\u1ED6NG | |
| # Gi\xE1 tr\u1ECB r\u1ED7ng khi\u1EBFn nginx B\u1ECE header \u0111i v\xE0 d\xF9ng keep-alive m\u1EB7c \u0111\u1ECBnh c\u1EE7a HTTP/1.1, | |
| # \u0111\xFAng th\u1EE9 m\xE0 'keepalive 32' trong kh\u1ED1i upstream c\u1EA7n. \xC9p c\u1EE9ng "upgrade" cho m\u1ECDi | |
| # request (bug c\u0169) s\u1EBD g\u1EEDi 'Connection: upgrade' k\xE8m 'Upgrade:' r\u1ED7ng \u2014 header m\xE9o, | |
| # \u0111\u1ED3ng th\u1EDDi ph\xE1 lu\xF4n keepalive t\u1EDBi upstream. | |
| map $http_upgrade $napp_connection_upgrade { | |
| default upgrade; | |
| '' ''; | |
| } | |
| # \u0110\u1ECBnh d\u1EA1ng log cho request qu\xE9t l\u1ED7 h\u1ED5ng b\u1ECB ch\u1EB7n (xem _scanner-block.conf). | |
| # PH\u1EA2I khai b\xE1o \u1EDF m\u1EE9c http nh\u01B0 \u1EDF \u0111\xE2y: 'log_format' kh\xF4ng h\u1EE3p l\u1EC7 trong server{}. | |
| # C\xF3 th\xEAm $host so v\u1EDBi 'combined' v\xEC file log l\xE0 D\xD9NG CHUNG cho m\u1ECDi site \u2014 thi\u1EBFu | |
| # c\u1ED9t \u0111\xF3 th\xEC bi\u1EBFt c\xF3 k\u1EBB \u0111ang qu\xE9t m\xE0 kh\xF4ng bi\u1EBFt n\xF3 qu\xE9t site n\xE0o. | |
| log_format ${NGINX_SCANNER_LOG_FORMAT} '$remote_addr - $host [$time_local] "$request" $status "$http_user_agent"'; | |
| ${PROXY_BUFFER_BLOCK}`; | |
| } | |
| var PROXY_BUFFER_BLOCK = `proxy_buffering on; | |
| proxy_buffer_size 128k; | |
| proxy_buffers 4 256k; | |
| proxy_busy_buffers_size 256k; | |
| `; | |
| function renderNginxHardeningConf() { | |
| return `# Managed by napp \u2014 hardening nginx (ch\u1EA1y \`napp nginx harden\`). | |
| # \u1EA8n phi\xEAn b\u1EA3n nginx trong header/response l\u1ED7i \u0111\u1EC3 \u0111\u1EE1 l\u1ED9 th\xF4ng tin cho k\u1EBB d\xF2 qu\xE9t. | |
| server_tokens off; | |
| `; | |
| } | |
| function renderDefaultServerConf(opts) { | |
| const v6_80 = opts.ipv6 ? "\n listen [::]:80 default_server;" : ""; | |
| const v6_443 = opts.ipv6 ? "\n listen [::]:443 ssl default_server;" : ""; | |
| const block443 = opts.sslMode === "reject" ? `server { | |
| listen 443 ssl default_server;${v6_443} | |
| server_name _; | |
| # T\u1EEB ch\u1ED1i ngay \u1EDF b\u01B0\u1EDBc b\u1EAFt tay TLS n\u1EBFu SNI kh\xF4ng kh\u1EDBp domain th\u1EADt n\xE0o. | |
| ssl_reject_handshake on; | |
| }` : `server { | |
| listen 443 ssl default_server;${v6_443} | |
| server_name _; | |
| # nginx c\u0169 ch\u01B0a h\u1ED7 tr\u1EE3 ssl_reject_handshake \u2014 d\xF9ng cert t\u1EF1 k\xFD r\u1ED3i \u0111\xF3ng 444. | |
| ssl_certificate ${opts.certPath}; | |
| ssl_certificate_key ${opts.keyPath}; | |
| ssl_protocols TLSv1.2 TLSv1.3; | |
| return 444; | |
| }`; | |
| return `# Managed by napp \u2014 CH\u1EB6N request kh\xF4ng kh\u1EDBp domain (truy c\u1EADp th\u1EB3ng IP, Host l\u1EA1). | |
| # T\u1EF0 SINH b\u1EDFi \`napp nginx harden\`; g\u1EE1 b\u1EB1ng \`napp nginx unharden\`. \u0110\u1EEANG s\u1EEDa tay. | |
| server { | |
| listen 80 default_server;${v6_80} | |
| server_name _; | |
| return 444; | |
| } | |
| ${block443} | |
| `; | |
| } | |
| function renderCloudflareRealIpSnippet(ipv4, ipv6) { | |
| const lines = [ | |
| "# Managed by napp \u2014 T\u1EF0 \u0110\u1ED8NG SINH RA, \u0111\u1EEBng s\u1EEDa tay (ch\u1EA1y `napp cloudflare sync` \u0111\u1EC3 c\u1EADp nh\u1EADt).", | |
| "# Kh\xF4i ph\u1EE5c IP client th\u1EADt khi request \u0111i qua Cloudflare proxy.", | |
| "# N\u1EBFu server KH\xD4NG d\xF9ng Cloudflare proxy cho m\u1ED9t site n\xE0o \u0111\xF3, \u0111\u01A1n gi\u1EA3n l\xE0", | |
| "# request s\u1EBD kh\xF4ng \u0111\u1EBFn t\u1EEB c\xE1c d\u1EA3i IP n\xE0y n\xEAn $remote_addr gi\u1EEF nguy\xEAn IP g\u1ED1c.", | |
| "" | |
| ]; | |
| for (const ip of ipv4) lines.push(`set_real_ip_from ${ip};`); | |
| for (const ip of ipv6) lines.push(`set_real_ip_from ${ip};`); | |
| lines.push(""); | |
| lines.push("real_ip_header CF-Connecting-IP;"); | |
| lines.push("real_ip_recursive on;"); | |
| lines.push(""); | |
| return lines.join("\n"); | |
| } | |
| var SCANNER_PATTERNS = [ | |
| { | |
| re: String.raw`\.(php[0-9]?|phtml|phps|asp|aspx|jsp|jspx|cfm|cgi|shtml)$`, | |
| why: "\u0111u\xF4i file c\u1EE7a runtime m\xE0 app Node KH\xD4NG BAO GI\u1EDC ph\u1EE5c v\u1EE5" | |
| }, | |
| { | |
| re: String.raw`^/(wp-admin|wp-content|wp-includes|wp-json|wordpress)/`, | |
| why: "namespace ri\xEAng c\u1EE7a WordPress" | |
| }, | |
| { | |
| re: String.raw`^/(phpmyadmin|phpmyadmin[0-9._-]*|pma|myadmin|mysqladmin|adminer|dbadmin)(/|$)`, | |
| why: "trang qu\u1EA3n tr\u1ECB database vi\u1EBFt b\u1EB1ng PHP" | |
| }, | |
| { re: String.raw`^/cgi-bin/`, why: "CGI c\u1ED5 \u0111i\u1EC3n (Shellshock v\xE0 h\u1ECD h\xE0ng)" } | |
| ]; | |
| function renderScannerBlockConf(enabled) { | |
| const header = `# Managed by napp \u2014 ch\u1EB7n qu\xE9t l\u1ED7 h\u1ED5ng (CMS/framework PHP). T\u1EF0 SINH, \u0111\u1EEBng s\u1EEDa tay. | |
| # B\u1EADt : napp nginx scanblock T\u1EAFt: napp nginx unscanblock | |
| # M\u1ED9t site c\u1EE5 th\u1EC3: napp app set <domain> --no-scan-block | |
| # File n\xE0y \u0111\u01B0\u1EE3c M\u1ECCI vhost napp qu\u1EA3n l\xFD include (m\u1ED9t d\xF2ng trong <domain>.conf). | |
| `; | |
| if (!enabled) { | |
| return header + `# | |
| # \u0110ANG T\u1EAET. File v\u1EABn t\u1ED3n t\u1EA1i v\xE0 v\u1EABn \u0111\u01B0\u1EE3c include \u2014 T\u1EAET ngh\u0129a l\xE0 l\xE0m R\u1ED6NG, | |
| # kh\xF4ng ph\u1EA3i xo\xE1: include tr\u1ECF v\xE0o file kh\xF4ng c\xF3 th\u1EADt khi\u1EBFn nginx t\u1EEB ch\u1ED1i | |
| # kh\u1EDFi \u0111\u1ED9ng tr\xEAn TO\xC0N M\xC1Y, s\u1EADp m\u1ECDi site ch\u1EE9 kh\xF4ng ri\xEAng site n\xE0o. | |
| `; | |
| } | |
| const blocks = SCANNER_PATTERNS.map( | |
| (p) => ` | |
| # ${p.why} | |
| location ~* ${p.re} { | |
| # Log sang file RI\xCANG, KH\xD4NG t\u1EAFt log \u2014 xem ch\xFA th\xEDch \u1EDF nginx.ts: | |
| # t\u1EAFt log l\xE0 fail2ban m\u1EA5t t\xEDn hi\u1EC7u v\xE0 scanner kh\xF4ng bao gi\u1EDD b\u1ECB ban. | |
| access_log ${NGINX_SCANNER_LOG} ${NGINX_SCANNER_LOG_FORMAT}; | |
| return 444; | |
| }` | |
| ).join("\n"); | |
| return `${header}# | |
| # 444 = \u0111\xF3ng k\u1EBFt n\u1ED1i, kh\xF4ng g\u1EEDi g\xEC c\u1EA3 (kh\xF4ng l\u1ED9 th\xF4ng tin, kh\xF4ng t\u1ED1n b\u0103ng th\xF4ng). | |
| # Request b\u1ECB ch\u1EB7n ghi v\xE0o ${NGINX_SCANNER_LOG} \u2014 m\u1ECDi d\xF2ng trong \u0111\xF3 | |
| # ch\u1EAFc ch\u1EAFn l\xE0 scanner, n\xEAn jail 'napp-scanner' c\u1EE7a fail2ban ban \u0111\u01B0\u1EE3c r\u1EA5t ch\u1EB7t | |
| # m\xE0 kh\xF4ng s\u1EE3 ban nh\u1EA7m. Xem: napp fail2ban setup | |
| ${blocks} | |
| `; | |
| } | |
| function renderAppLocationsConf(app2) { | |
| const staticRoot = app2.staticRoot; | |
| const staticPrefixes = app2.staticPrefixes; | |
| const uploadDir = app2.uploadDir; | |
| const uploadPrefix = app2.uploadPrefix ?? "/uploads/"; | |
| const hotlinkProtect = app2.hotlinkProtect ?? false; | |
| const hotlinkAllow = app2.hotlinkAllow; | |
| const hotlinkStrict = app2.hotlinkStrict ?? false; | |
| const corpApplies = hotlinkProtect && (hotlinkAllow?.length ?? 0) === 0; | |
| const corpHeader = corpApplies ? ` | |
| # Ch\u1EB7n hotlink do TR\xCCNH DUY\u1EC6T th\u1EF1c thi \u2014 trang nh\xFAng kh\xF4ng t\xE1c \u0111\u1ED9ng \u0111\u01B0\u1EE3c, | |
| # v\xE0 n\xF3 v\u1EABn hi\u1EC7u l\u1EF1c sau khi \u0111i qua cache CDN. Xem ch\xFA th\xEDch \u1EDF nginx.ts. | |
| add_header Cross-Origin-Resource-Policy "same-site" always;` : ""; | |
| const staticBlock = staticRoot && (staticPrefixes?.length ?? 0) > 0 ? ` | |
| root ${staticRoot}; | |
| ${staticPrefixes.map( | |
| (p) => ` | |
| location ^~ ${p} { | |
| try_files $uri =404; | |
| # Ba header b\u1EA3o m\u1EADt d\u01B0\u1EDBi \u0111\xE2y \u0111\u01B0\u1EE3c L\u1EB6P L\u1EA0I c\xF3 ch\u1EE7 \u0111\xEDch: ch\u1EC9 c\u1EA7n m\u1ED9t | |
| # 'add_header' trong location con l\xE0 nginx B\u1ECE TO\xC0N B\u1ED8 add_header k\u1EBF th\u1EEBa | |
| # t\u1EEB kh\u1ED1i server. Kh\xF4ng l\u1EB7p l\u1EA1i th\xEC ri\xEAng c\xE1c file t\u0129nh s\u1EBD m\u1EA5t | |
| # 'nosniff' \u2014 \u0111\xFAng lo\u1EA1i ph\u1EA3n h\u1ED3i c\u1EA7n n\xF3 nh\u1EA5t, v\xEC tr\xECnh duy\u1EC7t \u0111o\xE1n sai | |
| # ki\u1EC3u n\u1ED9i dung c\u1EE7a m\u1ED9t file .js l\xE0 m\u1ED9t vector t\u1EA5n c\xF4ng th\u1EADt s\u1EF1. | |
| add_header X-Frame-Options "SAMEORIGIN" always; | |
| add_header X-Content-Type-Options "nosniff" always; | |
| add_header Referrer-Policy "strict-origin-when-cross-origin" always; | |
| # M\u1ED9t 'Cache-Control' duy nh\u1EA5t. KH\xD4NG d\xF9ng k\xE8m 'expires' \u2014 expires c\u0169ng | |
| # sinh ra Cache-Control, v\xE0 hai ch\u1EC9 th\u1ECB c\xF9ng l\xFAc tr\u1EA3 v\u1EC1 HAI header. | |
| # 'immutable' m\u1EDBi l\xE0 ph\u1EA7n \u0111\xE1ng gi\xE1: n\xF3 b\u1ECF lu\xF4n b\u01B0\u1EDBc revalidate khi ng\u01B0\u1EDDi | |
| # d\xF9ng b\u1EA5m t\u1EA3i l\u1EA1i, th\u1EE9 m\xE0 'expires' m\u1ED9t m\xECnh kh\xF4ng l\xE0m \u0111\u01B0\u1EE3c. | |
| add_header Cache-Control "public, max-age=31536000, immutable" always;${corpHeader} | |
| access_log off; | |
| }` | |
| ).join("")} | |
| ` : ""; | |
| const aliasBlock = (app2.staticAliases ?? []).map( | |
| (a) => ` | |
| location ^~ ${a.prefix} { | |
| alias ${a.dir.replace(/\/+$/, "")}/; | |
| # L\u1EB7p l\u1EA1i ba header b\u1EA3o m\u1EADt v\xEC c\xF9ng l\xFD do nh\u01B0 kh\u1ED1i tr\xEAn: m\u1ED9t add_header | |
| # trong location con l\xE0 nginx b\u1ECF to\xE0n b\u1ED9 add_header k\u1EBF th\u1EEBa t\u1EEB server. | |
| add_header X-Frame-Options "SAMEORIGIN" always; | |
| add_header X-Content-Type-Options "nosniff" always; | |
| add_header Referrer-Policy "strict-origin-when-cross-origin" always; | |
| add_header Cache-Control "public, max-age=31536000, immutable" always;${corpHeader} | |
| access_log off; | |
| } | |
| ` | |
| ).join(""); | |
| const referers = hotlinkStrict ? ["server_names"] : ["none", "blocked", "server_names"]; | |
| const hotlinkBlock = hotlinkProtect ? ` | |
| # Ch\u1EC9 cho nh\xFAng t\u1EEB ch\xEDnh domain n\xE0y. Xem ch\xFA th\xEDch \u1EDF nginx.ts v\u1EC1 v\xEC sao | |
| # 'none' v\xE0 'blocked' \u0111\u01B0\u1EE3c ph\xE9p${hotlinkStrict ? " (\u0111\xE3 B\u1ECE v\xEC --hotlink-strict)" : ""}, v\xE0 v\xEC sao \u0111\xE2y kh\xF4ng ph\u1EA3i ki\u1EC3m so\xE1t truy c\u1EADp. | |
| valid_referers ${referers.join(" ")}${(hotlinkAllow?.length ?? 0) > 0 ? " " + hotlinkAllow.join(" ") : ""}; | |
| if ($invalid_referer) { return 403; } | |
| ` : ""; | |
| const uploadBlock = uploadDir ? ` | |
| location ^~ ${uploadPrefix} { | |
| alias ${uploadDir.replace(/\/+$/, "")}/; | |
| ${hotlinkBlock} | |
| add_header X-Frame-Options "SAMEORIGIN" always; | |
| add_header X-Content-Type-Options "nosniff" always; | |
| add_header Referrer-Policy "strict-origin-when-cross-origin" always; | |
| # Ng\u1EAFn h\u01A1n asset build r\u1EA5t nhi\u1EC1u: t\xEAn file t\u1EA3i l\xEAn KH\xD4NG b\u0103m n\u1ED9i dung, | |
| # n\xEAn c\xF9ng m\u1ED9t URL c\xF3 th\u1EC3 \u0111\u1ED5i n\u1ED9i dung. 'immutable' \u1EDF \u0111\xE2y s\u1EBD kho\xE1 b\u1EA3n c\u0169 | |
| # trong cache tr\xECnh duy\u1EC7t h\xE0ng n\u0103m tr\u1EDDi. | |
| add_header Cache-Control "public, max-age=86400" always;${corpHeader} | |
| access_log off; | |
| } | |
| ` : ""; | |
| const scannerInclude = app2.scanBlock === false ? ` | |
| # Ch\u1EB7n qu\xE9t l\u1ED7 h\u1ED5ng: \u0110\xC3 T\u1EAET cho site n\xE0y (napp app set ${app2.domain} --scan-block \u0111\u1EC3 b\u1EADt l\u1EA1i). | |
| ` : ` | |
| # Ch\u1EB7n qu\xE9t l\u1ED7 h\u1ED5ng (wp-admin, .php, phpmyadmin... -> 444, log ri\xEAng). | |
| # Danh s\xE1ch m\u1EABu d\xF9ng chung cho m\u1ECDi site, s\u1EEDa m\u1ED9t ch\u1ED7: ${NGINX_SCANNER_BLOCK_CONF} | |
| include ${NGINX_SCANNER_BLOCK_CONF}; | |
| `; | |
| const customInclude = ` | |
| # Location do B\u1EA0N vi\u1EBFt. napp KH\xD4NG BAO GI\u1EDC ghi \u0111\xE8 file d\u01B0\u1EDBi \u0111\xE2y \u2014 \u0111\u1EB7t | |
| # location ri\xEAng v\xE0o \u0111\xF3 thay v\xEC s\u1EEDa file n\xE0y (file n\xE0y b\u1ECB render l\u1EA1i | |
| # m\u1ED7i l\u1EA7n 'napp app set' / 'napp domain add' ch\u1EA1y). | |
| include ${appCustomLocationsPath(app2.domain)}; | |
| `; | |
| const out = `${staticBlock}${aliasBlock}${uploadBlock}`; | |
| return out.trim().length === 0 ? `# Managed by napp \u2014 site: ${app2.domain} | |
| # Ch\u01B0a b\u1EADt tu\u1EF3 ch\u1ECDn n\xE0o (--static-root / --static-alias / --upload-dir). File gi\u1EEF l\u1EA1i v\xEC vhost include n\xF3. | |
| ${scannerInclude}${customInclude}` : `# Managed by napp \u2014 location ri\xEAng c\u1EE7a ${app2.domain}. T\u1EF0 SINH, \u0111\u1EEBng s\u1EEDa tay. | |
| # C\u1EADp nh\u1EADt b\u1EB1ng: napp app set ${app2.domain} ... | |
| ${out}${scannerInclude}${customInclude}`; | |
| } | |
| function renderAppNginxConf(app2, opts = {}) { | |
| const allNames = [app2.domain, `www.${app2.domain}`, ...app2.aliasDomains, ...opts.extraServerNames ?? []]; | |
| const serverNames = Array.from(new Set(allNames)).join(" "); | |
| const maxBody = opts.clientMaxBodySize ?? app2.maxBodySize ?? "20M"; | |
| const ipv6Line = opts.ipv6 === false ? "" : "\n listen [::]:80;"; | |
| return `# Managed by napp \u2014 site: ${app2.domain} | |
| # Ch\u1EC9 HTTP. Ch\u1EA1y 'napp cert issue ${app2.domain}' \u0111\u1EC3 th\xEAm HTTPS (certbot t\u1EF1 s\u1EEDa file n\xE0y). | |
| upstream napp_${sanitizeUpstreamName(app2.domain)} { | |
| server 127.0.0.1:${app2.port}; | |
| keepalive 32; | |
| } | |
| server { | |
| listen 80;${ipv6Line} | |
| server_name ${serverNames}; | |
| access_log /var/log/nginx/${app2.domain}.access.log; | |
| error_log /var/log/nginx/${app2.domain}.error.log; | |
| client_max_body_size ${maxBody}; | |
| add_header X-Frame-Options "SAMEORIGIN" always; | |
| add_header X-Content-Type-Options "nosniff" always; | |
| add_header Referrer-Policy "strict-origin-when-cross-origin" always; | |
| # Location ri\xEAng c\u1EE7a app (asset build / file t\u1EA3i l\xEAn / ch\u1EB7n hotlink). | |
| # N\u1EB1m \u1EDF file ri\xEAng \u0111\u1EC3 \u0111\u1ED5i c\u1EA5u h\xECnh v\u1EC1 sau KH\xD4NG ph\u1EA3i render l\u1EA1i vhost n\xE0y \u2014 | |
| # certbot ch\xE8n kh\u1ED1i SSL v\xE0o \u0111\xE2y, render l\u1EA1i l\xE0 m\u1EA5t HTTPS. S\u1EEDa b\u1EB1ng: | |
| # napp app set ${app2.domain} --static-root ... --upload-dir ... | |
| include ${appLocationsPath(app2.domain)}; | |
| location = /favicon.ico { access_log off; log_not_found off; } | |
| location = /robots.txt { access_log off; log_not_found off; } | |
| location /health { | |
| proxy_pass http://napp_${sanitizeUpstreamName(app2.domain)}; | |
| access_log off; | |
| } | |
| location / { | |
| proxy_pass http://napp_${sanitizeUpstreamName(app2.domain)}; | |
| proxy_http_version 1.1; | |
| # WebSocket \u2014 ch\u1EC9 n\xE2ng c\u1EA5p khi client TH\u1EACT S\u1EF0 xin n\xE2ng c\u1EA5p. | |
| # $napp_connection_upgrade \u0111\u1ECBnh ngh\u0129a \u1EDF /etc/nginx/conf.d/00-napp-proxy.conf. | |
| proxy_set_header Upgrade $http_upgrade; | |
| proxy_set_header Connection $napp_connection_upgrade; | |
| # IP/host th\u1EADt c\u1EE7a client \u2014 nh\u1EDD napp_cloudflare_realip.conf, $remote_addr | |
| # \u1EDF \u0111\xE2y \u0110\xC3 L\xC0 IP th\u1EADt c\u1EE7a client (kh\xF4ng ph\u1EA3i IP Cloudflare edge) khi | |
| # request \u0111i qua Cloudflare proxy; n\u1EBFu kh\xF4ng qua Cloudflare th\xEC v\u1EABn | |
| # \u0111\xFAng l\xE0 IP k\u1EBFt n\u1ED1i tr\u1EF1c ti\u1EBFp. | |
| proxy_set_header Host $host; | |
| proxy_set_header X-Real-IP $remote_addr; | |
| proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; | |
| proxy_set_header X-Forwarded-Proto $scheme; | |
| proxy_set_header CF-Connecting-IP $http_cf_connecting_ip; | |
| proxy_set_header CF-Ray $http_cf_ray; | |
| proxy_connect_timeout 60s; | |
| proxy_send_timeout 60s; | |
| proxy_read_timeout 60s; | |
| # B\u1ED9 \u0111\u1EC7m proxy KH\xD4NG \u0111\u1EB7t \u1EDF \u0111\xE2y n\u1EEFa: n\xF3 n\u1EB1m \u1EDF m\u1EE9c http trong | |
| # /etc/nginx/conf.d/00-napp-proxy.conf v\xE0 \u0111\u01B0\u1EE3c k\u1EBF th\u1EEBa xu\u1ED1ng. \u0110\u1EB7t l\u1EA1i | |
| # trong t\u1EEBng vhost ngh\u0129a l\xE0 m\u1ED7i l\u1EA7n \u0111\u1ED5i gi\xE1 tr\u1ECB ph\u1EA3i s\u1EEDa l\u1EA1i vhost \u2014 | |
| # m\xE0 vhost l\xE0 ch\u1ED7 certbot ch\xE8n kh\u1ED1i SSL v\xE0o, render l\u1EA1i l\xE0 m\u1EA5t HTTPS. | |
| # Mu\u1ED1n ri\xEAng cho site n\xE0y th\xEC th\xEAm proxy_buffer_size/proxy_buffers v\xE0o | |
| # \u0111\xE2y, gi\xE1 tr\u1ECB trong location lu\xF4n th\u1EAFng gi\xE1 tr\u1ECB \u1EDF m\u1EE9c http. | |
| } | |
| location ~ /\\.(?!well-known).* { | |
| deny all; | |
| } | |
| } | |
| `; | |
| } | |
| function sanitizeUpstreamName(domain2) { | |
| return domain2.replace(/[^a-zA-Z0-9]/g, "_"); | |
| } | |
| function renderNginxTuningConf(cpuCores, tier) { | |
| const workerConnections = tier === "micro" ? 1024 : tier === "small" ? 2048 : 4096; | |
| return `# Managed by napp \u2014 T\u1EF0 \u0110\u1ED8NG SINH RA b\u1EDFi \`napp tune apply\` (ph\u1EA7n c\u1EE9ng: ${cpuCores} l\xF5i, tier ${tier}). | |
| keepalive_timeout 65; | |
| keepalive_requests 1000; | |
| client_body_timeout 12; | |
| client_header_timeout 12; | |
| send_timeout 10; | |
| gzip_vary on; | |
| # 'gzip on;' KH\xD4NG khai b\xE1o l\u1EA1i \u1EDF \u0111\xE2y v\xEC nginx.conf m\u1EB7c \u0111\u1ECBnh tr\xEAn Ubuntu \u0111\xE3 | |
| # b\u1EADt s\u1EB5n \u2014 khai b\xE1o l\u1EA1i s\u1EBD g\xE2y l\u1ED7i "gzip directive is duplicate". N\u1EBFu server | |
| # c\u1EE7a b\u1EA1n \u0111\xE3 t\u1EAFt gzip trong nginx.conf, h\xE3y b\u1EADt l\u1EA1i \u1EDF \u0111\xF3. | |
| # | |
| # gzip_proxied quy\u1EBFt \u0111\u1ECBnh c\xF3 n\xE9n hay kh\xF4ng khi REQUEST C\u1EE6A CLIENT mang header | |
| # 'Via' \u2014 nginx l\u1EA5y s\u1EF1 hi\u1EC7n di\u1EC7n c\u1EE7a Via l\xE0m d\u1EA5u hi\u1EC7u "request n\xE0y \u0111\xE3 \u0111i qua m\u1ED9t | |
| # proxy". \u0110\xE2y KH\xD4NG ph\u1EA3i l\xE0 "ph\u1EA3n h\u1ED3i \u0111\u1EBFn t\u1EEB upstream": kh\xF4ng c\xF3 Via th\xEC nginx | |
| # n\xE9n b\xECnh th\u01B0\u1EDDng b\u1EA5t k\u1EC3 c\xF3 proxy_pass hay kh\xF4ng. M\u1EB7c \u0111\u1ECBnh l\xE0 'off', v\xE0 tr\xEAn | |
| # Ubuntu d\xF2ng n\xE0y b\u1ECB comment s\u1EB5n trong nginx.conf. | |
| # | |
| # \u0110o th\u1EF1c t\u1EBF v\u1EDBi m\u1ED9t trang 132 KB, client g\u1EEDi 'Accept-Encoding: gzip': | |
| # kh\xF4ng Via -> n\xE9n trong c\u1EA3 hai tr\u01B0\u1EDDng h\u1EE3p | |
| # c\xF3 Via -> KH\xD4NG c\xF3 d\xF2ng n\xE0y: tr\u1EA3 nguy\xEAn 132 KB \xB7 c\xF3 d\xF2ng n\xE0y: n\xE9n | |
| # | |
| # Cloudflare kh\xF4ng g\u1EEDi Via n\xEAn site sau Cloudflare th\u01B0\u1EDDng kh\xF4ng d\xEDnh. Nh\u01B0ng | |
| # Fastly, Varnish, squid v\xE0 ph\u1EA7n l\u1EDBn proxy doanh nghi\u1EC7p th\xEC C\xD3 \u2014 v\xE0 khi d\xEDnh th\xEC | |
| # tri\u1EC7u ch\u1EE9ng l\xE0 "ch\u1EADm v\u1EDBi m\u1ED9t s\u1ED1 ng\u01B0\u1EDDi d\xF9ng", g\u1EA7n nh\u01B0 kh\xF4ng th\u1EC3 l\u1EA7n ra. | |
| gzip_proxied any; | |
| gzip_comp_level 5; | |
| gzip_min_length 256; | |
| gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; | |
| open_file_cache max=10000 inactive=60s; | |
| open_file_cache_valid 80s; | |
| open_file_cache_min_uses 2; | |
| open_file_cache_errors on; | |
| `; | |
| } | |
| // src/lib/locationsfile.ts | |
| function locationPrefixesIn(conf) { | |
| const out = []; | |
| const re = /location\s+\^~\s+(\S+)\s*\{/g; | |
| let m; | |
| while ((m = re.exec(conf)) !== null) out.push(m[1]); | |
| return out; | |
| } | |
| function ensureCustomLocationsFile(domain2) { | |
| const path = appCustomLocationsPath(domain2); | |
| if ((0, import_node_fs4.existsSync)(path)) return; | |
| writeFile( | |
| path, | |
| `# Location RI\xCANG c\u1EE7a b\u1EA1n cho ${domain2} \u2014 napp KH\xD4NG BAO GI\u1EDC ghi \u0111\xE8 file n\xE0y. | |
| # | |
| # File '<domain>.conf' b\xEAn c\u1EA1nh l\xE0 file T\u1EF0 SINH: 'napp app set' v\xE0 | |
| # 'napp domain add' render l\u1EA1i to\xE0n b\u1ED9 n\xF3 t\u1EEB registry, n\xEAn m\u1ECDi th\u1EE9 b\u1EA1n th\xEAm | |
| # v\xE0o \u0111\xF3 s\u1EBD bi\u1EBFn m\u1EA5t. \u0110\u1EB7t location ri\xEAng v\xE0o \u0110\xC2Y th\xEC ch\xFAng t\u1ED3n t\u1EA1i m\xE3i. | |
| # | |
| # File n\xE0y \u0111\u01B0\u1EE3c include B\xCAN TRONG kh\u1ED1i 'server' c\u1EE7a vhost, n\xEAn vi\u1EBFt th\u1EB3ng | |
| # c\xE1c kh\u1ED1i 'location ...' l\xE0 \u0111\u01B0\u1EE3c. Nh\u1EDB ch\u1EA1y 'nginx -t' tr\u01B0\u1EDBc khi reload. | |
| `, | |
| 420 | |
| ); | |
| } | |
| function ensureScannerBlockFile() { | |
| if ((0, import_node_fs4.existsSync)(NGINX_SCANNER_BLOCK_CONF)) return; | |
| writeFile(NGINX_SCANNER_BLOCK_CONF, renderScannerBlockConf(true), 420); | |
| } | |
| function writeAppLocationsConf(app2) { | |
| ensureDir(NGINX_LOCATIONS_DIR, 493); | |
| ensureCustomLocationsFile(app2.domain); | |
| ensureScannerBlockFile(); | |
| const path = appLocationsPath(app2.domain); | |
| const rendered = renderAppLocationsConf(app2); | |
| if ((0, import_node_fs4.existsSync)(path)) { | |
| const previous = (0, import_node_fs4.readFileSync)(path, "utf8"); | |
| const before = locationPrefixesIn(previous); | |
| const after = new Set(locationPrefixesIn(rendered)); | |
| const lost = before.filter((p) => !after.has(p)); | |
| if (lost.length > 0) { | |
| const bak = `${path}.napp-orphaned`; | |
| runCmd("cp", ["-a", path, bak], { silentFail: true }); | |
| warn( | |
| `${path}: ghi \u0111\xE8 s\u1EBD XO\xC1 ${lost.length} location \u0111ang ph\u1EE5c v\u1EE5 (${lost.join(" ")}). | |
| B\u1EA3n sao \u0111\u1EA7y \u0111\u1EE7 c\u1EE7a file c\u0169: ${bak} | |
| N\u1EBFu \u0111\xF3 l\xE0 location B\u1EA0N th\xEAm tay: chuy\u1EC3n n\xF3 sang ${appCustomLocationsPath(app2.domain)} \u2014 napp kh\xF4ng bao gi\u1EDD ghi \u0111\xE8 file \u0111\xF3. | |
| N\u1EBFu \u0111\xF3 l\xE0 c\u1EA5u h\xECnh napp (vd '/uploads/'): khai b\xE1o l\u1EA1i b\u1EB1ng 'napp app set ${app2.domain} --upload-dir <th\u01B0-m\u1EE5c>' \u0111\u1EC3 n\xF3 n\u1EB1m trong registry.` | |
| ); | |
| } | |
| } | |
| writeFile(path, rendered, 420); | |
| } | |
| function injectLocationsInclude(conf, upstreamMarker, includeLine) { | |
| if (conf.includes(includeLine)) return conf; | |
| const out = []; | |
| let i = 0; | |
| while (i < conf.length) { | |
| const at = conf.indexOf("server", i); | |
| if (at === -1) { | |
| out.push(conf.slice(i)); | |
| break; | |
| } | |
| const open = conf.indexOf("{", at); | |
| if (open === -1) { | |
| out.push(conf.slice(i)); | |
| break; | |
| } | |
| let depth = 0; | |
| let end = -1; | |
| for (let k = open; k < conf.length; k++) { | |
| if (conf[k] === "{") depth++; | |
| else if (conf[k] === "}") { | |
| depth--; | |
| if (depth === 0) { | |
| end = k; | |
| break; | |
| } | |
| } | |
| } | |
| if (end === -1) { | |
| out.push(conf.slice(i)); | |
| break; | |
| } | |
| const block = conf.slice(at, end + 1); | |
| out.push(conf.slice(i, at)); | |
| if (block.includes(upstreamMarker)) { | |
| out.push(block.slice(0, -1).replace(/\s*$/, "\n") + ` | |
| ${includeLine} | |
| }`); | |
| } else { | |
| out.push(block); | |
| } | |
| i = end + 1; | |
| } | |
| return out.join(""); | |
| } | |
| // src/commands/nginx.ts | |
| var DEFAULT_DENY_CERT = "/etc/napp/default-deny.crt"; | |
| var DEFAULT_DENY_KEY = "/etc/napp/default-deny.key"; | |
| function nginxSupportsRejectHandshake() { | |
| const res = execCapture("nginx", ["-v"]); | |
| const m = `${res.stderr}${res.stdout}`.match(/nginx\/(\d+)\.(\d+)\.(\d+)/); | |
| if (!m) return false; | |
| const maj = parseInt(m[1], 10); | |
| const min = parseInt(m[2], 10); | |
| const patch = parseInt(m[3], 10); | |
| if (maj !== 1) return maj > 1; | |
| if (min !== 19) return min > 19; | |
| return patch >= 4; | |
| } | |
| function ensureSelfSignedCert() { | |
| if (!(0, import_node_fs5.existsSync)(DEFAULT_DENY_CERT) || !(0, import_node_fs5.existsSync)(DEFAULT_DENY_KEY)) { | |
| if (!commandExists("openssl")) { | |
| die("C\u1EA7n 'openssl' \u0111\u1EC3 t\u1EA1o ch\u1EE9ng ch\u1EC9 t\u1EF1 k\xFD cho server ch\u1EB7n. C\xE0i: apt install -y openssl"); | |
| } | |
| ensureDir("/etc/napp", 488); | |
| runCmd("openssl", [ | |
| "req", | |
| "-x509", | |
| "-nodes", | |
| "-newkey", | |
| "rsa:2048", | |
| "-days", | |
| "3650", | |
| "-keyout", | |
| DEFAULT_DENY_KEY, | |
| "-out", | |
| DEFAULT_DENY_CERT, | |
| "-subj", | |
| "/CN=napp-default-deny" | |
| ]); | |
| runCmd("chmod", ["600", DEFAULT_DENY_KEY]); | |
| } | |
| return { certPath: DEFAULT_DENY_CERT, keyPath: DEFAULT_DENY_KEY }; | |
| } | |
| function ensureNappProxyConf() { | |
| const want = renderNappProxyConf(); | |
| if ((0, import_node_fs5.existsSync)(NGINX_PROXY_CONF) && (0, import_node_fs5.readFileSync)(NGINX_PROXY_CONF, "utf8") === want) return false; | |
| ensureDir("/etc/nginx/conf.d", 493); | |
| writeFile(NGINX_PROXY_CONF, want, 420); | |
| return true; | |
| } | |
| var LEGACY_CONNECTION_LINE = /proxy_set_header\s+Connection\s+"upgrade"\s*;/g; | |
| function stripInlineProxyBuffers(content) { | |
| const lines = content.split("\n"); | |
| const isBufferLine = (l) => /^\s*proxy_(buffering|buffer_size|buffers|busy_buffers_size)\s+[^;]*;\s*$/.test(l); | |
| const isComment = (l) => /^\s*#/.test(l); | |
| const keep = []; | |
| let changed = false; | |
| for (let i = 0; i < lines.length; i++) { | |
| if (!isBufferLine(lines[i])) { | |
| keep.push(lines[i]); | |
| continue; | |
| } | |
| changed = true; | |
| while (keep.length > 0 && isComment(keep[keep.length - 1])) keep.pop(); | |
| let j = i + 1; | |
| while (j < lines.length) { | |
| if (isBufferLine(lines[j])) { | |
| i = j; | |
| j = i + 1; | |
| continue; | |
| } | |
| if (isComment(lines[j])) { | |
| let k = j; | |
| while (k < lines.length && isComment(lines[k])) k++; | |
| if (k < lines.length && isBufferLine(lines[k])) { | |
| i = k; | |
| j = i + 1; | |
| continue; | |
| } | |
| } | |
| break; | |
| } | |
| } | |
| return { out: keep.join("\n"), changed }; | |
| } | |
| var ConfigTx = class { | |
| items = []; | |
| /** | |
| * Ghi nhớ trạng thái file. PHẢI gọi TRƯỚC khi ghi đè nó. | |
| * | |
| * copyFileSync là lệnh ghi THẬT, không đi qua writeFile/runCmd nên KHÔNG tự | |
| * biết --dry-run. Thiếu nhánh dryRun ở đây thì `napp nginx sync --dry-run` | |
| * rải '.napp-bak' khắp /etc/nginx rồi bỏ lại: bước dọn dẹp đi qua runCmd, mà | |
| * runCmd ở chế độ dry-run chỉ in ra chứ không xoá. Một lệnh mang tiếng "không | |
| * thay đổi gì" mà để lại rác là kiểu vi phạm hợp đồng khó chịu nhất. | |
| */ | |
| track(path) { | |
| if (this.items.some((i) => i.path === path)) return; | |
| if (!(0, import_node_fs5.existsSync)(path)) { | |
| this.items.push({ path }); | |
| return; | |
| } | |
| const backup2 = `${path}.napp-bak`; | |
| if (!state.dryRun) (0, import_node_fs5.copyFileSync)(path, backup2); | |
| this.items.push({ path, backup: backup2 }); | |
| } | |
| rollback() { | |
| for (const it of this.items) { | |
| if (it.backup) { | |
| if (!state.dryRun) (0, import_node_fs5.copyFileSync)(it.backup, it.path); | |
| } else { | |
| runCmd("rm", ["-f", it.path], { silentFail: true }); | |
| } | |
| } | |
| this.cleanup(); | |
| } | |
| /** Xoá các bản sao sau khi đã chắc chắn thành công. */ | |
| cleanup() { | |
| for (const it of this.items) if (it.backup) runCmd("rm", ["-f", it.backup], { silentFail: true }); | |
| this.items = []; | |
| } | |
| }; | |
| function scannerBlockEnabled() { | |
| if (!(0, import_node_fs5.existsSync)(NGINX_SCANNER_BLOCK_CONF)) return false; | |
| return /^\s*location\s/m.test((0, import_node_fs5.readFileSync)(NGINX_SCANNER_BLOCK_CONF, "utf8")); | |
| } | |
| function ensureScannerBlockConf(enabled) { | |
| const want = renderScannerBlockConf(enabled); | |
| if ((0, import_node_fs5.existsSync)(NGINX_SCANNER_BLOCK_CONF) && (0, import_node_fs5.readFileSync)(NGINX_SCANNER_BLOCK_CONF, "utf8") === want) return false; | |
| ensureDir(NGINX_LOCATIONS_DIR, 493); | |
| writeFile(NGINX_SCANNER_BLOCK_CONF, want, 420); | |
| return true; | |
| } | |
| function backfillAppLocations(app2, tx) { | |
| const locPath = appLocationsPath(app2.domain); | |
| const before = (0, import_node_fs5.existsSync)(locPath) ? (0, import_node_fs5.readFileSync)(locPath, "utf8") : null; | |
| tx.track(locPath); | |
| tx.track(appCustomLocationsPath(app2.domain)); | |
| writeAppLocationsConf(app2); | |
| const wroteLocations = before !== (0, import_node_fs5.readFileSync)(locPath, "utf8"); | |
| const vhostPath = `${NGINX_AVAILABLE}/${app2.domain}.conf`; | |
| const vhost = (0, import_node_fs5.readFileSync)(vhostPath, "utf8"); | |
| const withInclude = injectLocationsInclude(vhost, `proxy_pass http://napp_${slugFor(app2.domain)}`, `include ${locPath};`); | |
| if (withInclude === vhost) return { wroteLocations, patchedVhost: false }; | |
| tx.track(vhostPath); | |
| writeFile(vhostPath, withInclude, 420); | |
| return { wroteLocations, patchedVhost: true }; | |
| } | |
| function appsWithScanBlockOff() { | |
| return Object.values(loadState().apps).filter((a) => a.scanBlock === false).map((a) => a.domain); | |
| } | |
| function cmdNginxSync() { | |
| requireRoot(); | |
| if (!commandExists("nginx")) die("nginx ch\u01B0a \u0111\u01B0\u1EE3c c\xE0i. Ch\u1EA1y 'napp check --fix' tr\u01B0\u1EDBc."); | |
| section("\u0110\u1ED3ng b\u1ED9 c\u1EA5u h\xECnh nginx d\xF9ng chung cho c\xE1c vhost napp"); | |
| const tx = new ConfigTx(); | |
| tx.track(NGINX_PROXY_CONF); | |
| const wroteProxyConf = ensureNappProxyConf(); | |
| info( | |
| wroteProxyConf ? `\u0110\xE3 ghi ${NGINX_PROXY_CONF} (map $napp_connection_upgrade + log_format napp_scan).` : `${NGINX_PROXY_CONF} \u0111\xE3 \u0111\xFAng, gi\u1EEF nguy\xEAn.` | |
| ); | |
| const scannerExisted = (0, import_node_fs5.existsSync)(NGINX_SCANNER_BLOCK_CONF); | |
| const scannerOn = scannerExisted ? scannerBlockEnabled() : true; | |
| tx.track(NGINX_SCANNER_BLOCK_CONF); | |
| ensureScannerBlockConf(scannerOn); | |
| const patched = []; | |
| const debuffered = []; | |
| const includeAdded = []; | |
| const locationsWritten = []; | |
| for (const app2 of Object.values(loadState().apps)) { | |
| const conf = `${NGINX_AVAILABLE}/${app2.domain}.conf`; | |
| if (!(0, import_node_fs5.existsSync)(conf)) { | |
| warn(`B\u1ECF qua '${app2.domain}': kh\xF4ng th\u1EA5y ${conf}.`); | |
| continue; | |
| } | |
| const before = (0, import_node_fs5.readFileSync)(conf, "utf8"); | |
| const withConnection = before.replace(LEGACY_CONNECTION_LINE, "proxy_set_header Connection $napp_connection_upgrade;"); | |
| const stripped = stripInlineProxyBuffers(withConnection); | |
| if (stripped.changed) debuffered.push(app2.domain); | |
| if (stripped.out !== before) { | |
| tx.track(conf); | |
| writeFile(conf, stripped.out, 420); | |
| patched.push(app2.domain); | |
| } | |
| const res = backfillAppLocations(app2, tx); | |
| if (res.wroteLocations) locationsWritten.push(app2.domain); | |
| if (res.patchedVhost) includeAdded.push(app2.domain); | |
| } | |
| info(patched.length === 0 ? "Kh\xF4ng c\xF3 vhost n\xE0o c\u1EA7n v\xE1 ch\u1EC9 th\u1ECB c\u0169." : `\u0110\xE3 v\xE1 ch\u1EC9 th\u1ECB c\u0169 trong vhost: ${patched.join(", ")}`); | |
| if (debuffered.length > 0) { | |
| info(`\u0110\xE3 g\u1EE1 kh\u1ED1i b\u1ED9 \u0111\u1EC7m proxy n\u1ED9i tuy\u1EBFn (nay l\u1EA5y t\u1EEB ${NGINX_PROXY_CONF}) kh\u1ECFi: ${debuffered.join(", ")}`); | |
| } | |
| if (includeAdded.length > 0) { | |
| info(`\u0110\xE3 ch\xE8n d\xF2ng 'include' file location v\xE0o vhost CH\u01AFA c\xF3 (app t\u1EA1o b\u1EB1ng b\u1EA3n napp c\u0169): ${includeAdded.join(", ")}`); | |
| } | |
| if (locationsWritten.length > 0) { | |
| info(`\u0110\xE3 c\u1EADp nh\u1EADt file location: ${locationsWritten.join(", ")}`); | |
| } | |
| const test = execCapture("nginx", ["-t"]); | |
| if (test.code !== 0) { | |
| tx.rollback(); | |
| die(`C\u1EA5u h\xECnh nginx sau khi v\xE1 c\xF3 l\u1ED7i \u2014 \u0110\xC3 HO\xC0N T\xC1C to\xE0n b\u1ED9: | |
| ${test.stderr}`); | |
| } | |
| runCmd("systemctl", ["reload", "nginx"]); | |
| tx.cleanup(); | |
| ok("\u0110\xE3 \u0111\u1ED3ng b\u1ED9 v\xE0 reload nginx."); | |
| info("\u2022 'Connection: upgrade' gi\u1EDD CH\u1EC8 g\u1EEDi cho request WebSocket th\u1EADt; request th\u01B0\u1EDDng d\xF9ng keep-alive."); | |
| info("\u2022 B\u1ED9 \u0111\u1EC7m proxy: 128k header + 4x256k th\xE2n, \u0111\u1EB7t m\u1ED9t ch\u1ED7 \u1EDF m\u1EE9c http \u2014 \u0111\u1EE7 cho route SvelteKit l\u1ED3ng s\xE2u (tr\u01B0\u1EDBc \u0111\xE2y 502 'upstream sent too big header')."); | |
| info("\u2022 Kh\u1ED1i SSL do certbot ch\xE8n trong vhost \u0111\u01B0\u1EE3c gi\u1EEF nguy\xEAn (v\xE1 t\u1EA1i ch\u1ED7, kh\xF4ng render l\u1EA1i)."); | |
| if (scannerOn) { | |
| reportScannerBlock(scannerExisted ? "gi\u1EEF nguy\xEAn (\u0111ang B\u1EACT)" : "B\u1EACT l\u1EA7n \u0111\u1EA7u"); | |
| } else { | |
| info(`\u2022 Ch\u1EB7n qu\xE9t l\u1ED7 h\u1ED5ng: \u0111ang T\u1EAET (gi\u1EEF nguy\xEAn l\u1EF1a ch\u1ECDn c\u0169). B\u1EADt l\u1EA1i: napp nginx scanblock`); | |
| } | |
| } | |
| function reportScannerBlock(stateLabel) { | |
| info(`\u2022 Ch\u1EB7n qu\xE9t l\u1ED7 h\u1ED5ng: ${stateLabel} \u2014 '.php/.asp/.jsp', '/wp-admin/', '/phpmyadmin/', '/cgi-bin/' -> 444, KH\xD4NG qua Node.`); | |
| info(` Ghi log ri\xEAng \u1EDF ${NGINX_SCANNER_LOG} (access log c\u1EE7a site s\u1EA1ch tr\u1EDF l\u1EA1i): tail -f ${NGINX_SCANNER_LOG}`); | |
| info(` Ch\u1EA1y 'napp fail2ban setup' \u0111\u1EC3 b\u1EADt jail 'napp-scanner' \u2014 ban IP ngay t\u1EEB t\u01B0\u1EDDng l\u1EEDa, th\u1EE9 TH\u1EACT S\u1EF0 ti\u1EBFt ki\u1EC7m t\xE0i nguy\xEAn (444 v\u1EABn ph\u1EA3i tr\u1EA3 ti\u1EC1n b\u1EAFt tay TLS).`); | |
| const off = appsWithScanBlockOff(); | |
| if (off.length > 0) info(` \u0110ang T\u1EAET ri\xEAng cho: ${off.join(", ")} (b\u1EADt l\u1EA1i: napp app set <domain> --scan-block)`); | |
| } | |
| function cmdNginxScanBlock() { | |
| requireRoot(); | |
| if (!commandExists("nginx")) die("nginx ch\u01B0a \u0111\u01B0\u1EE3c c\xE0i. Ch\u1EA1y 'napp check --fix' tr\u01B0\u1EDBc."); | |
| section("Ch\u1EB7n qu\xE9t l\u1ED7 h\u1ED5ng CMS/framework PHP (tr\u1EA3 444, log ri\xEAng)"); | |
| const tx = new ConfigTx(); | |
| tx.track(NGINX_PROXY_CONF); | |
| ensureNappProxyConf(); | |
| tx.track(NGINX_SCANNER_BLOCK_CONF); | |
| ensureScannerBlockConf(true); | |
| const includeAdded = []; | |
| const touched = []; | |
| for (const app2 of Object.values(loadState().apps)) { | |
| if (!(0, import_node_fs5.existsSync)(`${NGINX_AVAILABLE}/${app2.domain}.conf`)) { | |
| warn(`B\u1ECF qua '${app2.domain}': kh\xF4ng th\u1EA5y vhost ${NGINX_AVAILABLE}/${app2.domain}.conf.`); | |
| continue; | |
| } | |
| const res = backfillAppLocations(app2, tx); | |
| if (res.patchedVhost) includeAdded.push(app2.domain); | |
| if (res.wroteLocations || res.patchedVhost) touched.push(app2.domain); | |
| } | |
| const test = execCapture("nginx", ["-t"]); | |
| if (test.code !== 0) { | |
| tx.rollback(); | |
| die(`C\u1EA5u h\xECnh nginx sau khi b\u1EADt ch\u1EB7n qu\xE9t c\xF3 l\u1ED7i \u2014 \u0110\xC3 HO\xC0N T\xC1C to\xE0n b\u1ED9: | |
| ${test.stderr}`); | |
| } | |
| runCmd("systemctl", ["reload", "nginx"]); | |
| tx.cleanup(); | |
| ok("\u0110\xE3 b\u1EADt ch\u1EB7n qu\xE9t l\u1ED7 h\u1ED5ng cho m\u1ECDi site napp qu\u1EA3n l\xFD."); | |
| if (includeAdded.length > 0) { | |
| info(`\u2022 \u0110\xE3 ch\xE8n d\xF2ng 'include' v\xE0o vhost ch\u01B0a c\xF3 (app t\u1EA1o b\u1EB1ng b\u1EA3n napp c\u0169): ${includeAdded.join(", ")}`); | |
| } | |
| if (touched.length === 0) info("\u2022 M\u1ECDi site \u0111\xE3 \u1EDF \u0111\xFAng c\u1EA5u h\xECnh, kh\xF4ng c\xF3 g\xEC ph\u1EA3i \u0111\u1ED5i."); | |
| reportScannerBlock("B\u1EACT"); | |
| warn("Danh s\xE1ch m\u1EABu c\u1ED1 \xFD H\u1EB8P (neo theo \u0111u\xF4i .php/.asp/.jsp v\xE0 namespace WordPress/phpMyAdmin) \u0111\u1EC3 kh\xF4ng th\u1EC3 ch\u1EB7n nh\u1EA7m route th\u1EADt c\u1EE7a app Node."); | |
| warn(`N\u1EBFu m\u1ED9t site c\u1EE7a b\u1EA1n TH\u1EACT S\u1EF0 ph\u1EE5c v\u1EE5 file .php qua upstream kh\xE1c: napp app set <domain> --no-scan-block`); | |
| } | |
| function cmdNginxUnscanBlock() { | |
| requireRoot(); | |
| if (!commandExists("nginx")) die("nginx ch\u01B0a \u0111\u01B0\u1EE3c c\xE0i."); | |
| if (!(0, import_node_fs5.existsSync)(NGINX_SCANNER_BLOCK_CONF)) { | |
| info("Ch\u1EB7n qu\xE9t l\u1ED7 h\u1ED5ng ch\u01B0a t\u1EEBng \u0111\u01B0\u1EE3c b\u1EADt \u2014 kh\xF4ng c\xF3 g\xEC \u0111\u1EC3 g\u1EE1."); | |
| return; | |
| } | |
| if (!scannerBlockEnabled()) { | |
| info("Ch\u1EB7n qu\xE9t l\u1ED7 h\u1ED5ng \u0111ang T\u1EAET s\u1EB5n. B\u1EADt l\u1EA1i: napp nginx scanblock"); | |
| return; | |
| } | |
| const tx = new ConfigTx(); | |
| tx.track(NGINX_SCANNER_BLOCK_CONF); | |
| ensureScannerBlockConf(false); | |
| const test = execCapture("nginx", ["-t"]); | |
| if (test.code !== 0) { | |
| tx.rollback(); | |
| die(`C\u1EA5u h\xECnh nginx sau khi g\u1EE1 c\xF3 l\u1ED7i \u2014 \u0110\xC3 HO\xC0N T\xC1C: | |
| ${test.stderr}`); | |
| } | |
| runCmd("systemctl", ["reload", "nginx"]); | |
| tx.cleanup(); | |
| ok("\u0110\xE3 t\u1EAFt ch\u1EB7n qu\xE9t l\u1ED7 h\u1ED5ng. Request d\xF2 .php/wp-admin l\u1EA1i \u0111i qua Node v\xE0 quay l\u1EA1i access log c\u1EE7a site."); | |
| info(`\u2022 D\xF2ng 'include ${NGINX_SCANNER_BLOCK_CONF};' v\u1EABn n\u1EB1m trong vhost (file nay r\u1ED7ng) \u2014 b\u1EADt l\u1EA1i ch\u1EC9 c\u1EA7n: napp nginx scanblock`); | |
| } | |
| function cmdNginxHarden() { | |
| requireRoot(); | |
| if (!commandExists("nginx")) die("nginx ch\u01B0a \u0111\u01B0\u1EE3c c\xE0i. Ch\u1EA1y 'napp check --fix' tr\u01B0\u1EDBc."); | |
| section("Hardening nginx + ch\u1EB7n truy c\u1EADp IP / Host l\u1EA1"); | |
| ensureDir("/etc/nginx/conf.d", 493); | |
| const stockDefault = "/etc/nginx/sites-enabled/default"; | |
| if ((0, import_node_fs5.existsSync)(stockDefault)) { | |
| runCmd("rm", ["-f", stockDefault], { silentFail: true }); | |
| info("\u0110\xE3 v\xF4 hi\u1EC7u site 'default' m\u1EB7c \u0111\u1ECBnh c\u1EE7a Ubuntu (g\u1EE1 symlink sites-enabled/default)."); | |
| } | |
| const ipv6 = ipv6Available(); | |
| const sslMode = nginxSupportsRejectHandshake() ? "reject" : "selfsigned"; | |
| let certPath; | |
| let keyPath; | |
| if (sslMode === "selfsigned") { | |
| ({ certPath, keyPath } = ensureSelfSignedCert()); | |
| info("nginx < 1.19.4 \u2014 d\xF9ng ch\u1EE9ng ch\u1EC9 t\u1EF1 k\xFD cho server ch\u1EB7n HTTPS (thay ssl_reject_handshake)."); | |
| } | |
| writeFile(NGINX_HARDENING_CONF, renderNginxHardeningConf(), 420); | |
| writeFile(NGINX_DEFAULT_SERVER_CONF, renderDefaultServerConf({ ipv6, sslMode, certPath, keyPath }), 420); | |
| const test = execCapture("nginx", ["-t"]); | |
| if (test.code !== 0) { | |
| runCmd("rm", ["-f", NGINX_DEFAULT_SERVER_CONF, NGINX_HARDENING_CONF], { silentFail: true }); | |
| die(`C\u1EA5u h\xECnh nginx sau hardening c\xF3 l\u1ED7i \u2014 \u0110\xC3 HO\xC0N T\xC1C (g\u1EE1 file v\u1EEBa ghi): | |
| ${test.stderr}`); | |
| } | |
| runCmd("systemctl", ["reload", "nginx"]); | |
| ok("\u0110\xE3 b\u1EADt hardening nginx."); | |
| info("\u2022 Request t\u1EDBi IP m\xE1y ch\u1EE7 ho\u1EB7c Host KH\xD4NG kh\u1EDBp domain n\xE0o -> b\u1ECB ch\u1EB7n (HTTP 444: \u0111\xF3ng k\u1EBFt n\u1ED1i)."); | |
| info("\u2022 Ch\u1EC9 domain \u0111\xE3 t\u1EA1o app (server_name kh\u1EDBp) m\u1EDBi truy c\u1EADp \u0111\u01B0\u1EE3c."); | |
| info("\u2022 \u0110\xE3 \u1EA9n phi\xEAn b\u1EA3n nginx (server_tokens off)."); | |
| warn("N\u1EBFu b\u1EA1n c\xF3 d\u1ECBch v\u1EE5 kh\xE1c c\u1EA7n truy c\u1EADp qua IP tr\u1EF1c ti\u1EBFp, h\xE3y c\xE2n nh\u1EAFc tr\u01B0\u1EDBc \u2014 ho\u1EB7c 'napp nginx unharden' \u0111\u1EC3 g\u1EE1."); | |
| } | |
| function cmdNginxUnharden() { | |
| requireRoot(); | |
| if (!commandExists("nginx")) die("nginx ch\u01B0a \u0111\u01B0\u1EE3c c\xE0i."); | |
| let removed = false; | |
| for (const f of [NGINX_DEFAULT_SERVER_CONF, NGINX_HARDENING_CONF]) { | |
| if ((0, import_node_fs5.existsSync)(f)) { | |
| runCmd("rm", ["-f", f], { silentFail: true }); | |
| removed = true; | |
| } | |
| } | |
| if (!removed) { | |
| info("Kh\xF4ng c\xF3 c\u1EA5u h\xECnh hardening n\xE0o c\u1EE7a napp \u0111\u1EC3 g\u1EE1."); | |
| return; | |
| } | |
| const test = execCapture("nginx", ["-t"]); | |
| if (test.code !== 0) die(`C\u1EA5u h\xECnh nginx sau khi g\u1EE1 c\xF3 l\u1ED7i: | |
| ${test.stderr}`); | |
| runCmd("systemctl", ["reload", "nginx"]); | |
| ok("\u0110\xE3 g\u1EE1 hardening nginx (server ch\u1EB7n IP/Host l\u1EA1). Truy c\u1EADp IP tr\u1EF1c ti\u1EBFp s\u1EBD theo h\xE0nh vi m\u1EB7c \u0111\u1ECBnh c\u1EE7a nginx tr\u1EDF l\u1EA1i."); | |
| } | |
| // src/commands/app.ts | |
| var import_node_fs13 = require("node:fs"); | |
| // src/lib/validate.ts | |
| var DOMAIN_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)+$/; | |
| function validateDomain(d) { | |
| if (!DOMAIN_RE.test(d)) { | |
| die(`T\xEAn mi\u1EC1n kh\xF4ng h\u1EE3p l\u1EC7: '${d}' (v\xED d\u1EE5 h\u1EE3p l\u1EC7: api.example.com)`); | |
| } | |
| if (d.length > 253) die(`T\xEAn mi\u1EC1n qu\xE1 d\xE0i: ${d}`); | |
| } | |
| function validateServiceName(name) { | |
| if (!/^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/.test(name) || name.length > 63) { | |
| die(`T\xEAn service kh\xF4ng h\u1EE3p l\u1EC7: '${name}' (ch\u1EC9 ch\u1EEF th\u01B0\u1EDDng, s\u1ED1, g\u1EA1ch ngang; v\xED d\u1EE5: worker-telegram, queue-email).`); | |
| } | |
| } | |
| function validatePort(p) { | |
| if (!Number.isInteger(p) || p < 1 || p > 65535) { | |
| die(`C\u1ED5ng kh\xF4ng h\u1EE3p l\u1EC7: ${p} (ph\u1EA3i trong kho\u1EA3ng 1-65535)`); | |
| } | |
| } | |
| function validateRepoUrl(url) { | |
| if (!url) die("URL git r\u1ED7ng."); | |
| if (url.startsWith("-")) die(`URL git kh\xF4ng \u0111\u01B0\u1EE3c b\u1EAFt \u0111\u1EA7u b\u1EB1ng '-': ${url}`); | |
| if (/[\s\x00-\x1f]/.test(url)) { | |
| die("URL git ch\u1EE9a kho\u1EA3ng tr\u1EAFng/k\xFD t\u1EF1 \u0111i\u1EC1u khi\u1EC3n kh\xF4ng h\u1EE3p l\u1EC7."); | |
| } | |
| if (/^(ext|fd)::/.test(url) || url.startsWith("file://")) { | |
| die(`Transport git b\u1ECB c\u1EA5m v\xEC l\xFD do b\u1EA3o m\u1EADt: ${url}`); | |
| } | |
| const okPrefix = /^(https:\/\/|http:\/\/|git:\/\/|ssh:\/\/)/.test(url); | |
| const okScp = /^[^@\s]+@[^:\s]+:.+$/.test(url); | |
| if (!okPrefix && !okScp) { | |
| die( | |
| `URL git kh\xF4ng h\u1EE3p l\u1EC7: ${url} | |
| Ch\u1EC9 ch\u1EA5p nh\u1EADn https://, http://, ssh://, git:// ho\u1EB7c d\u1EA1ng user@host:path.` | |
| ); | |
| } | |
| } | |
| function validateBranch(branch) { | |
| if (!/^[A-Za-z0-9._/-]{1,200}$/.test(branch) || branch.startsWith("-")) { | |
| die(`T\xEAn branch kh\xF4ng h\u1EE3p l\u1EC7: '${branch}'`); | |
| } | |
| } | |
| function validateEnvKey(key) { | |
| if (!/^[A-Z_][A-Z0-9_]*$/.test(key)) { | |
| die(`T\xEAn bi\u1EBFn m\xF4i tr\u01B0\u1EDDng kh\xF4ng h\u1EE3p l\u1EC7: '${key}' (ch\u1EC9 ch\u1EEF hoa, s\u1ED1, g\u1EA1ch d\u01B0\u1EDBi, kh\xF4ng b\u1EAFt \u0111\u1EA7u b\u1EB1ng s\u1ED1)`); | |
| } | |
| } | |
| function validateDbName(name) { | |
| if (!/^[a-zA-Z0-9_]{1,64}$/.test(name)) { | |
| die(`T\xEAn database kh\xF4ng h\u1EE3p l\u1EC7: '${name}' (ch\u1EC9 ch\u1EEF, s\u1ED1, g\u1EA1ch d\u01B0\u1EDBi, t\u1ED1i \u0111a 64 k\xFD t\u1EF1)`); | |
| } | |
| } | |
| function timeToDailyOnCalendar(time) { | |
| const m = time.match(/^(\d{1,2}):(\d{2})$/); | |
| if (!m) die(`\u0110\u1ECBnh d\u1EA1ng gi\u1EDD kh\xF4ng h\u1EE3p l\u1EC7: '${time}' (v\xED d\u1EE5 h\u1EE3p l\u1EC7: 03:30)`); | |
| const hh = parseInt(m[1], 10); | |
| const mm = parseInt(m[2], 10); | |
| if (hh > 23 || mm > 59) die(`Gi\u1EDD/ph\xFAt kh\xF4ng h\u1EE3p l\u1EC7: '${time}'`); | |
| return `*-*-* ${String(hh).padStart(2, "0")}:${String(mm).padStart(2, "0")}:00`; | |
| } | |
| // src/lib/lock.ts | |
| var import_node_fs6 = require("node:fs"); | |
| var LOCK_DIR = "/run/lock"; | |
| function lockPath(domain2) { | |
| return `${LOCK_DIR}/napp-${domain2}.lock`; | |
| } | |
| function pidAlive(pid) { | |
| try { | |
| process.kill(pid, 0); | |
| return true; | |
| } catch { | |
| return false; | |
| } | |
| } | |
| function acquireLock(domain2) { | |
| if (state.dryRun) return () => { | |
| }; | |
| const path = lockPath(domain2); | |
| if ((0, import_node_fs6.existsSync)(path)) { | |
| const raw = (0, import_node_fs6.readFileSync)(path, "utf8").trim(); | |
| const pid = parseInt(raw, 10); | |
| if (Number.isFinite(pid) && pidAlive(pid)) { | |
| die(`\u0110ang c\xF3 m\u1ED9t ti\u1EBFn tr\xECnh napp kh\xE1c (PID ${pid}) thao t\xE1c tr\xEAn '${domain2}'. H\xE3y \u0111\u1EE3i n\xF3 ho\xE0n t\u1EA5t r\u1ED3i th\u1EED l\u1EA1i.`); | |
| } | |
| warn(`T\xECm th\u1EA5y lock c\u0169 c\u1EE7a '${domain2}' (PID ${raw} \u0111\xE3 ch\u1EBFt) \u2014 t\u1EF1 d\u1ECDn d\u1EB9p.`); | |
| try { | |
| (0, import_node_fs6.unlinkSync)(path); | |
| } catch { | |
| } | |
| } | |
| try { | |
| const fd = (0, import_node_fs6.openSync)(path, import_node_fs6.constants.O_CREAT | import_node_fs6.constants.O_EXCL | import_node_fs6.constants.O_WRONLY, 420); | |
| (0, import_node_fs6.writeSync)(fd, String(process.pid)); | |
| (0, import_node_fs6.closeSync)(fd); | |
| } catch { | |
| die(`Kh\xF4ng t\u1EA1o \u0111\u01B0\u1EE3c lock file ${path}. C\xF3 th\u1EC3 do \u0111ua tranh \u2014 h\xE3y th\u1EED l\u1EA1i.`); | |
| } | |
| let released = false; | |
| return () => { | |
| if (released) return; | |
| released = true; | |
| try { | |
| (0, import_node_fs6.unlinkSync)(path); | |
| } catch { | |
| } | |
| }; | |
| } | |
| // src/lib/mysql.ts | |
| var import_node_crypto = require("node:crypto"); | |
| function mysqlBin() { | |
| if (commandExists("mysql")) return "mysql"; | |
| if (commandExists("mariadb")) return "mariadb"; | |
| die("Kh\xF4ng t\xECm th\u1EA5y mysql/mariadb client. Ch\u1EA1y 'napp check --fix' \u0111\u1EC3 c\xE0i MariaDB."); | |
| } | |
| function dbServiceRunning() { | |
| const viaSystemd = execCapture("systemctl", ["is-active", "--quiet", "mariadb"]).code === 0 || execCapture("systemctl", ["is-active", "--quiet", "mysql"]).code === 0; | |
| if (viaSystemd) return true; | |
| const viaProcess = execCapture("bash", ["-lc", "pgrep -x mysqld >/dev/null 2>&1 || pgrep -x mariadbd >/dev/null 2>&1"]).code === 0; | |
| return viaProcess; | |
| } | |
| var SYSTEM_DBS = /* @__PURE__ */ new Set(["information_schema", "performance_schema", "mysql", "sys"]); | |
| function listDatabases() { | |
| if (!commandExists("mysql") && !commandExists("mariadb")) return []; | |
| const bin = mysqlBin(); | |
| const res = execCapture(bin, ["-N", "-e", "SHOW DATABASES"]); | |
| if (res.code !== 0) return []; | |
| return res.stdout.trim().split("\n").map((s) => s.trim()).filter((d) => d && !SYSTEM_DBS.has(d)).sort(); | |
| } | |
| function dbExists(name) { | |
| const bin = mysqlBin(); | |
| const res = execCapture(bin, [ | |
| "-N", | |
| "-e", | |
| `SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME='${name.replace(/'/g, "")}'` | |
| ]); | |
| return res.code === 0 && res.stdout.trim().length > 0; | |
| } | |
| function canConnectAsAdmin() { | |
| const bin = mysqlBin(); | |
| return execCapture(bin, ["-e", "SELECT 1;"]).code === 0; | |
| } | |
| function randomPassword() { | |
| return (0, import_node_crypto.randomBytes)(16).toString("hex"); | |
| } | |
| function createDatabase(name, user) { | |
| if (!state.dryRun) { | |
| if (!canConnectAsAdmin()) { | |
| if (!dbServiceRunning()) { | |
| die("MariaDB/MySQL ch\u01B0a ch\u1EA1y \u2014 kh\xF4ng th\u1EC3 t\u1EA1o database. H\xE3y 'systemctl start mariadb' (ho\u1EB7c kh\u1EDFi \u0111\u1ED9ng d\u1ECBch v\u1EE5 t\u01B0\u01A1ng \u1EE9ng) ho\u1EB7c b\u1ECF tu\u1EF3 ch\u1ECDn --db."); | |
| } | |
| die( | |
| "Kh\xF4ng k\u1EBFt n\u1ED1i \u0111\u01B0\u1EE3c MariaDB/MySQL b\u1EB1ng quy\u1EC1n qu\u1EA3n tr\u1ECB.\n Ubuntu m\u1EB7c \u0111\u1ECBnh cho ph\xE9p root k\u1EBFt n\u1ED1i qua unix_socket (ch\u1EA1y napp b\u1EB1ng sudo).\n N\u1EBFu root CSDL c\xF3 m\u1EADt kh\u1EA9u: t\u1EA1o file /root/.my.cnf v\u1EDBi [client] user+password." | |
| ); | |
| } | |
| if (dbExists(name)) { | |
| die(`Database '${name}' \u0111\xE3 t\u1ED3n t\u1EA1i \u2014 kh\xF4ng ghi \u0111\xE8. H\xE3y t\u1EF1 x\u1EED l\xFD ho\u1EB7c b\u1ECF tu\u1EF3 ch\u1ECDn --db.`); | |
| } | |
| } | |
| const password = randomPassword(); | |
| const sql = `CREATE DATABASE \`${name}\` CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; | |
| CREATE USER '${user}'@'localhost' IDENTIFIED BY '${password}'; | |
| GRANT ALL PRIVILEGES ON \`${name}\`.* TO '${user}'@'localhost'; | |
| FLUSH PRIVILEGES;`; | |
| if (state.dryRun) { | |
| dryRunNotice(`S\u1EBD t\u1EA1o database '${name}' + user '${user}'@'localhost' (m\u1EADt kh\u1EA9u ng\u1EABu nhi\xEAn)`); | |
| return { name, user, password: "<dry-run>" }; | |
| } | |
| const bin = mysqlBin(); | |
| runCmd(bin, [], { input: sql }); | |
| return { name, user, password }; | |
| } | |
| function dropDatabase(name, user) { | |
| const bin = mysqlBin(); | |
| const parts = [`DROP DATABASE IF EXISTS \`${name}\`;`]; | |
| if (user) parts.push(`DROP USER IF EXISTS '${user}'@'localhost';`); | |
| parts.push("FLUSH PRIVILEGES;"); | |
| runCmd(bin, [], { input: parts.join("\n") }); | |
| } | |
| function dumpDatabase(name, outPath) { | |
| runCmd("bash", [ | |
| "-lc", | |
| `mysqldump --single-transaction --quick --routines --triggers ${JSON.stringify(name)} | gzip > ${JSON.stringify(outPath)}` | |
| ]); | |
| } | |
| function dumpAllDatabases(outPath) { | |
| runCmd("bash", [ | |
| "-lc", | |
| `mysqldump --all-databases --routines --triggers --events --single-transaction --quick | gzip > ${JSON.stringify(outPath)}` | |
| ]); | |
| } | |
| // src/lib/envfile.ts | |
| var import_node_fs7 = require("node:fs"); | |
| function parseEnvFile(path) { | |
| if (!(0, import_node_fs7.existsSync)(path)) return {}; | |
| const out = {}; | |
| for (const line of (0, import_node_fs7.readFileSync)(path, "utf8").split("\n")) { | |
| const trimmed = line.trim(); | |
| if (!trimmed || trimmed.startsWith("#")) continue; | |
| const eq = trimmed.indexOf("="); | |
| if (eq === -1) continue; | |
| const key = trimmed.slice(0, eq).trim(); | |
| let val = trimmed.slice(eq + 1).trim(); | |
| if (val.startsWith('"') && val.endsWith('"') || val.startsWith("'") && val.endsWith("'")) { | |
| val = val.slice(1, -1); | |
| } | |
| out[key] = val; | |
| } | |
| return out; | |
| } | |
| function serializeEnv(vars) { | |
| return Object.entries(vars).map(([k, v]) => `${k}=${needsQuote(v) ? JSON.stringify(v) : v}`).join("\n") + "\n"; | |
| } | |
| function needsQuote(v) { | |
| return /[\s#"'$]/.test(v); | |
| } | |
| function mergeEnvFile(path, updates, mode = 384) { | |
| const current = parseEnvFile(path); | |
| const merged = { ...current, ...updates }; | |
| writeFile(path, serializeEnv(merged), mode); | |
| } | |
| // src/templates/systemd.ts | |
| function unitWorkDir(root, appDir) { | |
| const sub = (appDir ?? "").trim().replace(/^\/+|\/+$/g, ""); | |
| return sub ? `${root}/${sub}` : root; | |
| } | |
| function renderUnit(spec) { | |
| const nodeOptionsLine = spec.nodeOptions ? `Environment="NODE_OPTIONS=${spec.nodeOptions}" | |
| ` : ""; | |
| const forcedEnvLines = spec.forcedEnv.map((e) => `Environment=${e}`).join("\n"); | |
| const writePaths = [.../* @__PURE__ */ new Set([spec.rootDir ?? spec.workDir, ...spec.extraWritePaths ?? []])]; | |
| const memoryHighLine = spec.memoryHighMB ? `# Gi\u1EDBi h\u1EA1n M\u1EC0M: v\u01B0\u1EE3t ng\u01B0\u1EE1ng th\xEC kernel throttle + thu h\u1ED3i b\u1ED9 nh\u1EDB c\u1EE7a ri\xEAng | |
| # \u0111\u01A1n v\u1ECB n\xE0y, KH\xD4NG gi\u1EBFt ti\u1EBFn tr\xECnh (kh\xE1c MemoryMax). C\u1EA7n cgroup v2. | |
| MemoryHigh=${spec.memoryHighMB}M | |
| ` : ""; | |
| return `${spec.headerComment} | |
| # S\u1EEDa tay file n\xE0y \u0110\u01AF\u1EE2C. napp so fingerprint \u1EDF d\xF2ng tr\xEAn \u0111\u1EC3 bi\u1EBFt b\u1EA1n \u0111\xE3 \u0111\u1ED5i | |
| # directive n\xE0o (ExecStart, StandardOutput/StandardError, User, Group, ...) v\xE0 | |
| # GI\u1EEE NGUY\xCAN b\u1EA3n c\u1EE7a b\u1EA1n \u1EDF nh\u1EEFng l\u1EA7n ghi sau; ph\u1EA7n hardening v\u1EABn \u0111\u01B0\u1EE3c c\u1EADp nh\u1EADt. | |
| # Mu\u1ED1n kho\xE1 tr\u01B0\u1EDBc m\u1ED9t directive: th\xEAm d\xF2ng '# napp-preserve: T\xEAn1 T\xEAn2'. | |
| [Unit] | |
| Description=${spec.description} | |
| After=network.target mariadb.service redis-server.service | |
| Wants=network-online.target | |
| StartLimitIntervalSec=60 | |
| StartLimitBurst=5 | |
| [Service] | |
| Type=simple | |
| User=${spec.user} | |
| Group=${spec.user} | |
| WorkingDirectory=${spec.workDir} | |
| ${nodeOptionsLine}EnvironmentFile=-${spec.workDir}/.env | |
| ${forcedEnvLines} | |
| ExecStart=${spec.execStart} | |
| Restart=always | |
| RestartSec=5 | |
| TimeoutStopSec=15 | |
| # --- Hardening --- | |
| NoNewPrivileges=yes | |
| ProtectSystem=strict | |
| # tmpfs (thay v\xEC yes): v\u1EABn GI\u1EA4U m\u1ECDi th\u01B0 m\u1EE5c home th\u1EADt, nh\u01B0ng c\u1EA5p cho service m\u1ED9t | |
| # $HOME r\u1ED7ng GHI \u0110\u01AF\u1EE2C (ephemeral) \u2014 th\xE2n thi\u1EC7n v\u1EDBi runtime hay ghi cache v\xE0o | |
| # home (bun ~/.bun, node ~/.npm) m\xE0 kh\xF4ng l\u1ED9 d\u1EEF li\u1EC7u ng\u01B0\u1EDDi d\xF9ng. | |
| ProtectHome=tmpfs | |
| PrivateTmp=yes | |
| ReadWritePaths=${writePaths.join(" ")} | |
| ProtectKernelTunables=yes | |
| ProtectKernelModules=yes | |
| ProtectControlGroups=yes | |
| RestrictSUIDSGID=yes | |
| LockPersonality=yes | |
| # --- Gi\u1EDBi h\u1EA1n t\xE0i nguy\xEAn --- | |
| LimitNOFILE=65535 | |
| # \u01AFu ti\xEAn t\u01B0\u01A1ng \u0111\u1ED1i KHI C\xD3 TRANH CH\u1EA4P (m\u1EB7c \u0111\u1ECBnh systemd l\xE0 100). Web app \u0111\u1EB7t cao | |
| # h\u01A1n background service \u0111\u1EC3 m\u1ED9t worker n\xE9n \u1EA3nh/video kh\xF4ng l\xE0m ch\u1EADm request c\u1EE7a | |
| # ng\u01B0\u1EDDi d\xF9ng th\u1EADt. Kh\xF4ng c\xF3 tranh ch\u1EA5p th\xEC kh\xF4ng ai b\u1ECB gi\u1EDBi h\u1EA1n: m\u1ED9t ti\u1EBFn tr\xECnh | |
| # r\u1EA3nh kh\xF4ng gi\u1EEF ch\u1ED7 CPU n\xE0o c\u1EA3. | |
| CPUWeight=${spec.cpuWeight} | |
| # IOWeight CH\u1EC8 hi\u1EC7u l\u1EF1c v\u1EDBi I/O scheduler 'bfq'; scheduler kh\xE1c th\xEC d\xF2ng n\xE0y v\xF4 | |
| # h\u1EA1i nh\u01B0ng kh\xF4ng l\xE0m g\xEC. 'napp tune show' n\xF3i r\xF5 m\xE1y n\xE0y c\xF3 thu\u1ED9c nh\xF3m n\xE0o. | |
| IOWeight=${spec.ioWeight} | |
| ${memoryHighLine} | |
| # --- Log --- | |
| StandardOutput=append:${spec.logBase}.out.log | |
| StandardError=append:${spec.logBase}.error.log | |
| [Install] | |
| WantedBy=multi-user.target | |
| `; | |
| } | |
| function renderAppSystemdService(app2, execStart, opts = {}) { | |
| return renderUnit({ | |
| headerComment: `# Managed by napp \u2014 site: ${app2.domain}`, | |
| description: `napp application - ${app2.domain}`, | |
| user: app2.user, | |
| // Monorepo: chạy TỪ thư mục con chứa ứng dụng. Quan trọng vì Node phân giải | |
| // import trần bằng cách đi ngược lên từ file gọi, mà pnpm chỉ symlink gói | |
| // vào node_modules của package đó — chạy từ gốc repo thì một gói có thật | |
| // vẫn báo ERR_MODULE_NOT_FOUND. `.env` cũng nằm cạnh ứng dụng, không ở gốc. | |
| workDir: unitWorkDir(app2.webRoot, app2.appDir), | |
| rootDir: app2.webRoot, | |
| logBase: `/var/log/napp/${app2.domain}`, | |
| execStart, | |
| nodeOptions: opts.nodeOptions, | |
| // Web app phục vụ traffic thật -> ưu tiên cao hơn background service. | |
| cpuWeight: CPU_WEIGHT_WEB, | |
| ioWeight: IO_WEIGHT_WEB, | |
| // KHÔNG đặt MemoryHigh cho web app: throttle đúng thứ đang phục vụ người | |
| // dùng là làm ngược lại mục đích của cả cơ chế này. | |
| forcedEnv: [`NODE_ENV=production`, `PORT=${app2.port}`] | |
| }); | |
| } | |
| function renderServiceSystemdService(svc, execStart, opts = {}) { | |
| const forcedEnv = [`NODE_ENV=production`]; | |
| if (svc.port !== void 0) forcedEnv.push(`PORT=${svc.port}`); | |
| return renderUnit({ | |
| headerComment: `# Managed by napp \u2014 service: ${svc.name}${svc.runAsUnit ? ` (ch\u1EA1y b\u1EB1ng user c\u1EE7a '${svc.runAsUnit}')` : ""}`, | |
| description: `napp background service - ${svc.name}`, | |
| user: svc.user, | |
| workDir: unitWorkDir(svc.workDir, svc.appDir), | |
| rootDir: svc.workDir, | |
| extraWritePaths: svc.writePaths, | |
| logBase: `/var/log/napp/${svc.name}`, | |
| execStart, | |
| nodeOptions: opts.nodeOptions, | |
| cpuWeight: CPU_WEIGHT_SERVICE, | |
| ioWeight: IO_WEIGHT_SERVICE, | |
| memoryHighMB: opts.memoryHighMB, | |
| forcedEnv | |
| }); | |
| } | |
| function execStartLine(startCmd) { | |
| const escaped = startCmd.replace(/'/g, `'\\''`); | |
| return `/bin/bash -lc '${escaped}'`; | |
| } | |
| function renderBackupService(scriptPath) { | |
| return `# Managed by napp \u2014 backup \u0111\u1ECBnh k\u1EF3 (database + source code) | |
| [Unit] | |
| Description=napp scheduled backup (database + source code) | |
| After=network.target mariadb.service | |
| [Service] | |
| Type=oneshot | |
| ExecStart=${scriptPath} | |
| Nice=10 | |
| IOSchedulingClass=best-effort | |
| IOSchedulingPriority=7 | |
| `; | |
| } | |
| function renderBackupTimer(onCalendar) { | |
| return `# Managed by napp \u2014 l\u1ECBch ch\u1EA1y backup | |
| [Unit] | |
| Description=napp scheduled backup timer | |
| [Timer] | |
| OnCalendar=${onCalendar} | |
| Persistent=true | |
| RandomizedDelaySec=120 | |
| [Install] | |
| WantedBy=timers.target | |
| `; | |
| } | |
| function renderMemwatchService(binPath) { | |
| return `# Managed by napp \u2014 l\u1EA5y m\u1EABu b\u1ED9 nh\u1EDB c\xE1c \u0111\u01A1n v\u1ECB node (ph\xE1t hi\u1EC7n r\xF2 r\u1EC9 s\u1EDBm) | |
| [Unit] | |
| Description=napp memory sampler (leak detection) | |
| [Service] | |
| Type=oneshot | |
| ExecStart=${binPath} mem sample --quiet | |
| # Vi\u1EC7c l\u1EA5y m\u1EABu ch\u1EC9 l\xE0 \u0111\u1ECDc v\xE0i file trong /sys/fs/cgroup \u2014 nh\u01B0\u1EDDng h\u1EB3n CPU/\u0111\u0129a | |
| # cho app, v\xEC m\u1ED9t c\xF4ng c\u1EE5 ch\u1EA9n \u0111o\xE1n m\xE0 l\xE0m ch\u1EADm ch\xEDnh th\u1EE9 n\xF3 theo d\xF5i th\xEC v\xF4 l\xFD. | |
| Nice=15 | |
| IOSchedulingClass=idle | |
| `; | |
| } | |
| function renderMemwatchTimer(onCalendar) { | |
| return `# Managed by napp \u2014 l\u1ECBch l\u1EA5y m\u1EABu b\u1ED9 nh\u1EDB | |
| [Unit] | |
| Description=napp memory sampler timer | |
| [Timer] | |
| OnCalendar=${onCalendar} | |
| Persistent=true | |
| # L\u1EC7ch ng\u1EABu nhi\xEAn \u0111\u1EC3 nhi\u1EC1u m\xE1y c\xF9ng c\u1EA5u h\xECnh kh\xF4ng l\u1EA5y m\u1EABu \u0111\xFAng c\xF9ng m\u1ED9t gi\xE2y. | |
| RandomizedDelaySec=30 | |
| [Install] | |
| WantedBy=timers.target | |
| `; | |
| } | |
| function renderCloudflareSyncService(binPath) { | |
| return `# Managed by napp \u2014 \u0111\u1ED3ng b\u1ED9 \u0111\u1ECBnh k\u1EF3 d\u1EA3i IP Cloudflare v\xE0o nginx (real-IP) | |
| [Unit] | |
| Description=napp Cloudflare IP sync (nginx real-IP) | |
| [Service] | |
| Type=oneshot | |
| ExecStart=${binPath} cloudflare sync --quiet | |
| `; | |
| } | |
| function renderCloudflareSyncTimer(onCalendar) { | |
| return `# Managed by napp \u2014 l\u1ECBch \u0111\u1ED3ng b\u1ED9 IP Cloudflare v\xE0o nginx (real-IP) | |
| [Unit] | |
| Description=napp Cloudflare IP sync timer | |
| [Timer] | |
| OnCalendar=${onCalendar} | |
| Persistent=true | |
| RandomizedDelaySec=300 | |
| [Install] | |
| WantedBy=timers.target | |
| `; | |
| } | |
| // src/lib/unitfile.ts | |
| var import_node_crypto2 = require("node:crypto"); | |
| var import_node_fs8 = require("node:fs"); | |
| var FINGERPRINT_KEY = "# napp-fingerprint:"; | |
| var PRESERVE_KEY = "# napp-preserve:"; | |
| var PRESERVE_NOTE_KEY = "# ^ "; | |
| var PRESERVABLE_DIRECTIVES = [ | |
| "ExecStart", | |
| "ExecStartPre", | |
| "ExecStartPost", | |
| "ExecReload", | |
| "ExecStop", | |
| "ExecStopPost", | |
| "StandardOutput", | |
| "StandardError", | |
| "StandardInput", | |
| "SyslogIdentifier", | |
| "User", | |
| "Group", | |
| "UMask", | |
| "WorkingDirectory", | |
| "Restart", | |
| "RestartSec", | |
| "TimeoutStartSec", | |
| "TimeoutStopSec", | |
| "LimitNOFILE", | |
| "Nice", | |
| "OOMScoreAdjust", | |
| "MemoryMax", | |
| "MemoryHigh", | |
| "CPUQuota", | |
| "CPUWeight", | |
| "IOWeight" | |
| ]; | |
| function fingerprintOf(text) { | |
| const body = text.split("\n").filter((l) => !l.startsWith(FINGERPRINT_KEY)).join("\n"); | |
| return (0, import_node_crypto2.createHash)("sha256").update(body).digest("hex"); | |
| } | |
| function recordedFingerprint(text) { | |
| const line = text.split("\n").find((l) => l.startsWith(FINGERPRINT_KEY)); | |
| return line?.slice(FINGERPRINT_KEY.length).trim() || void 0; | |
| } | |
| function recordedPreserves(text) { | |
| const out = []; | |
| for (const line of text.split("\n")) { | |
| if (!line.startsWith(PRESERVE_KEY)) continue; | |
| out.push(...line.slice(PRESERVE_KEY.length).trim().split(/[\s,]+/).filter(Boolean)); | |
| } | |
| return [...new Set(out)]; | |
| } | |
| function unitStatus(path) { | |
| if (!(0, import_node_fs8.existsSync)(path)) return "missing"; | |
| const text = (0, import_node_fs8.readFileSync)(path, "utf8"); | |
| const recorded = recordedFingerprint(text); | |
| if (!recorded) return "unknown"; | |
| return recorded === fingerprintOf(text) ? "managed" : "customized"; | |
| } | |
| var SERVICE_SECTION = "[Service]"; | |
| function scanServiceDirectives(lines) { | |
| const blocks = []; | |
| let inService = false; | |
| for (let i = 0; i < lines.length; i++) { | |
| const line = lines[i] ?? ""; | |
| const trimmed = line.trim(); | |
| if (trimmed.startsWith("[") && trimmed.endsWith("]")) { | |
| inService = trimmed === SERVICE_SECTION; | |
| continue; | |
| } | |
| if (!inService) continue; | |
| const m = /^([A-Za-z][A-Za-z0-9]*)=/.exec(trimmed); | |
| if (!m?.[1]) continue; | |
| const block = { name: m[1], start: i, lines: [line] }; | |
| while ((block.lines[block.lines.length - 1] ?? "").trimEnd().endsWith("\\") && i + 1 < lines.length) { | |
| i++; | |
| block.lines.push(lines[i] ?? ""); | |
| } | |
| blocks.push(block); | |
| } | |
| return blocks; | |
| } | |
| function serviceSectionEnd(lines) { | |
| let start = -1; | |
| for (let i = 0; i < lines.length; i++) { | |
| const t = (lines[i] ?? "").trim(); | |
| if (t === SERVICE_SECTION) { | |
| start = i; | |
| continue; | |
| } | |
| if (start >= 0 && t.startsWith("[") && t.endsWith("]")) return i; | |
| } | |
| return start >= 0 ? lines.length : -1; | |
| } | |
| function blocksNamed(blocks, name) { | |
| return blocks.filter((b) => b.name === name); | |
| } | |
| function blockText(blocks) { | |
| return blocks.flatMap((b) => b.lines).join("\n"); | |
| } | |
| function replaceServiceDirective(lines, name, replacement) { | |
| const blocks = blocksNamed(scanServiceDirectives(lines), name); | |
| if (blocks.length === 0) { | |
| const end = serviceSectionEnd(lines); | |
| if (end < 0) return lines; | |
| return [...lines.slice(0, end), ...replacement, ...lines.slice(end)]; | |
| } | |
| const out = []; | |
| const removed = /* @__PURE__ */ new Set(); | |
| for (const b of blocks) for (let i = 0; i < b.lines.length; i++) removed.add(b.start + i); | |
| const firstStart = blocks[0]?.start ?? -1; | |
| for (let i = 0; i < lines.length; i++) { | |
| if (i === firstStart) out.push(...replacement); | |
| if (removed.has(i)) continue; | |
| out.push(lines[i] ?? ""); | |
| } | |
| return out; | |
| } | |
| function stripMarkers(lines) { | |
| return lines.filter((l) => !l.startsWith(FINGERPRINT_KEY) && !l.startsWith(PRESERVE_KEY) && !l.startsWith(PRESERVE_NOTE_KEY)); | |
| } | |
| function stamp(text, preserved) { | |
| const lines = stripMarkers(text.split("\n")); | |
| const markers = []; | |
| if (preserved.length > 0) { | |
| markers.push(`${PRESERVE_KEY} ${[...preserved].sort().join(" ")}`); | |
| markers.push(`${PRESERVE_NOTE_KEY}directive do B\u1EA0N l\xE0m ch\u1EE7 \u2014 napp s\u1EBD kh\xF4ng ghi \u0111\xE8. Xo\xE1 t\xEAn kh\u1ECFi d\xF2ng tr\xEAn \u0111\u1EC3 tr\u1EA3 l\u1EA1i cho napp.`); | |
| } | |
| const at = lines[0]?.startsWith("#") ? 1 : 0; | |
| const withMarkers = [...lines.slice(0, at), ...markers, ...lines.slice(at)]; | |
| const fp = fingerprintOf(withMarkers.join("\n")); | |
| return [...withMarkers.slice(0, at), `${FINGERPRINT_KEY} ${fp}`, ...withMarkers.slice(at)].join("\n"); | |
| } | |
| function writeManagedUnit(path, rendered, opts = {}) { | |
| const authoritative = new Set(opts.authoritative ?? []); | |
| const status = unitStatus(path); | |
| if (status === "missing") { | |
| writeFile(path, stamp(rendered, []), 420); | |
| return { action: "created", status, preserved: [], overridden: [] }; | |
| } | |
| const existing = (0, import_node_fs8.readFileSync)(path, "utf8"); | |
| const existingLines = existing.split("\n"); | |
| const existingBlocks = scanServiceDirectives(existingLines); | |
| const renderedLines = rendered.split("\n"); | |
| const renderedBlocks = scanServiceDirectives(renderedLines); | |
| const candidates = new Set(recordedPreserves(existing)); | |
| if (status !== "managed") { | |
| for (const name of PRESERVABLE_DIRECTIVES) { | |
| const mine = blocksNamed(existingBlocks, name); | |
| if (mine.length === 0) continue; | |
| if (blockText(mine) !== blockText(blocksNamed(renderedBlocks, name))) candidates.add(name); | |
| } | |
| } | |
| const preserved = []; | |
| const overridden = []; | |
| let out = renderedLines; | |
| for (const name of [...candidates].sort()) { | |
| const mine = blocksNamed(existingBlocks, name); | |
| if (mine.length === 0) continue; | |
| if (authoritative.has(name)) { | |
| if (blockText(mine) !== blockText(blocksNamed(renderedBlocks, name))) overridden.push(name); | |
| continue; | |
| } | |
| out = replaceServiceDirective(out, name, mine.flatMap((b) => b.lines)); | |
| preserved.push(name); | |
| } | |
| const text = stamp(out.join("\n"), preserved); | |
| if (text === existing) return { action: "unchanged", status, preserved, overridden }; | |
| writeFile(path, text, 420); | |
| return { action: preserved.length > 0 ? "merged" : "rewritten", status, preserved, overridden }; | |
| } | |
| var HEAP_FLAG = /--max-old-space-size=\d+/; | |
| function patchUnitPriority(path, want) { | |
| if (!(0, import_node_fs8.existsSync)(path)) return { changed: false, applied: [], preserved: [] }; | |
| const original = (0, import_node_fs8.readFileSync)(path, "utf8"); | |
| const locked = new Set(recordedPreserves(original)); | |
| const wasManaged = unitStatus(path) === "managed"; | |
| let lines = original.split("\n"); | |
| const applied = []; | |
| const preserved = []; | |
| for (const [name, value] of Object.entries(want)) { | |
| if (locked.has(name)) { | |
| preserved.push(name); | |
| continue; | |
| } | |
| const blocks = scanServiceDirectives(lines).filter((b) => b.name === name); | |
| if (blocks.length === 1 && (blocks[0]?.lines[0] ?? "").trim() === `${name}=${value}`) continue; | |
| lines = replaceServiceDirective(lines, name, [`${name}=${value}`]); | |
| applied.push(name); | |
| } | |
| if (applied.length === 0) return { changed: false, applied, preserved }; | |
| const patched = lines.join("\n"); | |
| if (patched === original) return { changed: false, applied: [], preserved }; | |
| writeFile(path, wasManaged ? stamp(patched, recordedPreserves(original)) : patched, 420); | |
| return { changed: true, applied, preserved }; | |
| } | |
| function unitHasPriority(path) { | |
| if (!(0, import_node_fs8.existsSync)(path)) return false; | |
| const names = new Set(scanServiceDirectives((0, import_node_fs8.readFileSync)(path, "utf8").split("\n")).map((b) => b.name)); | |
| return names.has("CPUWeight"); | |
| } | |
| function patchUnitHeap(path, heapMB) { | |
| if (!(0, import_node_fs8.existsSync)(path)) return { changed: false, note: "unit kh\xF4ng t\u1ED3n t\u1EA1i" }; | |
| const original = (0, import_node_fs8.readFileSync)(path, "utf8"); | |
| if (recordedPreserves(original).includes("Environment")) { | |
| return { changed: false, note: "Environment n\u1EB1m trong '# napp-preserve:' \u2014 b\u1ECF qua" }; | |
| } | |
| const wasManaged = unitStatus(path) === "managed"; | |
| const lines = original.split("\n"); | |
| const nodeOptionLines = scanServiceDirectives(lines).filter((b) => b.name === "Environment" && /^Environment="?NODE_OPTIONS=/.test((b.lines[0] ?? "").trim())).map((b) => b.start); | |
| let next; | |
| let previous; | |
| let note; | |
| if (nodeOptionLines.length === 0) { | |
| const idx = lines.findIndex((l) => l.trim().startsWith("EnvironmentFile=")); | |
| const at = idx >= 0 ? idx : serviceSectionEnd(lines); | |
| if (at < 0) return { changed: false, note: "kh\xF4ng t\xECm th\u1EA5y kh\u1ED1i [Service]" }; | |
| next = [...lines.slice(0, at), `Environment=NODE_OPTIONS=--max-old-space-size=${heapMB}`, ...lines.slice(at)]; | |
| } else { | |
| const i = nodeOptionLines[0] ?? 0; | |
| const before = lines[i] ?? ""; | |
| const m = HEAP_FLAG.exec(before); | |
| if (m) previous = parseInt(m[0].slice(m[0].indexOf("=") + 1), 10); | |
| const patched = m ? before.replace(HEAP_FLAG, `--max-old-space-size=${heapMB}`) : ( | |
| // Có NODE_OPTIONS nhưng chưa có cờ heap: nối thêm, giữ nguyên cờ cũ. | |
| before.replace( | |
| /^(\s*Environment=)("?)NODE_OPTIONS=(.*?)("?)\s*$/, | |
| (_s, head, q1, val, q2) => `${head}${q1}NODE_OPTIONS=${val ? `${val} ` : ""}--max-old-space-size=${heapMB}${q2}` | |
| ) | |
| ); | |
| if (nodeOptionLines.length > 1) { | |
| note = `unit c\xF3 ${nodeOptionLines.length} d\xF2ng Environment=NODE_OPTIONS \u2014 ch\u1EC9 v\xE1 d\xF2ng \u0111\u1EA7u, d\xF2ng sau c\u1EE7a b\u1EA1n v\u1EABn l\xE0 d\xF2ng c\xF3 hi\u1EC7u l\u1EF1c`; | |
| } | |
| next = [...lines]; | |
| next[i] = patched; | |
| } | |
| const patchedText = next.join("\n"); | |
| if (patchedText === original) return { changed: false, previous, note }; | |
| writeFile(path, wasManaged ? stamp(patchedText, recordedPreserves(original)) : patchedText, 420); | |
| return { changed: true, previous, note }; | |
| } | |
| // src/lib/hardware.ts | |
| var import_node_os = __toESM(require("node:os")); | |
| var import_node_fs9 = require("node:fs"); | |
| function tierFor(totalMemMB) { | |
| if (totalMemMB <= 1536) return "micro"; | |
| if (totalMemMB <= 3072) return "small"; | |
| if (totalMemMB <= 6144) return "medium"; | |
| if (totalMemMB <= 12288) return "large"; | |
| return "xlarge"; | |
| } | |
| function detectHardware() { | |
| const cpuCores = import_node_os.default.cpus().length || 1; | |
| const totalMemMB = Math.round(import_node_os.default.totalmem() / 1024 / 1024); | |
| const freeMemMB = Math.round(import_node_os.default.freemem() / 1024 / 1024); | |
| let diskFreeGB = 0; | |
| const df = execCapture("df", ["-BG", "--output=avail", "/"]); | |
| if (df.code === 0) { | |
| const lines = df.stdout.trim().split("\n"); | |
| const last = lines[lines.length - 1]?.trim().replace("G", ""); | |
| diskFreeGB = last ? parseInt(last, 10) || 0 : 0; | |
| } | |
| return { | |
| cpuCores, | |
| totalMemMB, | |
| freeMemMB, | |
| diskFreeGB, | |
| tier: tierFor(totalMemMB) | |
| }; | |
| } | |
| function formatHardware(h) { | |
| return [ | |
| `CPU : ${h.cpuCores} l\xF5i`, | |
| `RAM t\u1ED5ng : ${(h.totalMemMB / 1024).toFixed(1)} GB (c\xF2n tr\u1ED1ng ~${(h.freeMemMB / 1024).toFixed(1)} GB)`, | |
| `\u1ED4 \u0111\u0129a tr\u1ED1ng: ${h.diskFreeGB} GB`, | |
| `Ph\xE2n h\u1EA1ng : ${h.tier}` | |
| ].join("\n"); | |
| } | |
| function selectedScheduler(dev) { | |
| const path = `/sys/block/${dev}/queue/scheduler`; | |
| if (!(0, import_node_fs9.existsSync)(path)) return void 0; | |
| try { | |
| const m = /\[([a-z-]+)\]/.exec((0, import_node_fs9.readFileSync)(path, "utf8")); | |
| return m?.[1]; | |
| } catch { | |
| return void 0; | |
| } | |
| } | |
| var resourceControlCache; | |
| function detectResourceControl() { | |
| if (resourceControlCache) return resourceControlCache; | |
| const fsType = execCapture("stat", ["-fc", "%T", "/sys/fs/cgroup"]); | |
| const cgroupV2 = fsType.code === 0 && fsType.stdout.trim() === "cgroup2fs"; | |
| const schedulers = []; | |
| try { | |
| for (const dev of (0, import_node_fs9.readdirSync)("/sys/block")) { | |
| if (/^(loop|ram|zram|sr)\d*$/.test(dev)) continue; | |
| const sched = selectedScheduler(dev); | |
| if (sched) schedulers.push(sched); | |
| } | |
| } catch { | |
| } | |
| resourceControlCache = { | |
| cgroupV2, | |
| ioWeightEffective: schedulers.includes("bfq"), | |
| ioSchedulers: [...new Set(schedulers)] | |
| }; | |
| return resourceControlCache; | |
| } | |
| function formatResourceControl(rc) { | |
| const out = []; | |
| out.push( | |
| rc.cgroupV2 ? " CPUWeight/MemoryHigh : C\xD3 hi\u1EC7u l\u1EF1c (cgroup v2)" : " CPUWeight : c\xF3 hi\u1EC7u l\u1EF1c qua CPUShares (cgroup v1 \u2014 systemd t\u1EF1 quy \u0111\u1ED5i)\n MemoryHigh : KH\xD4NG c\xF3 (ch\u1EC9 t\u1ED3n t\u1EA1i \u1EDF cgroup v2) \u2014 n\xE2ng l\xEAn Ubuntu 22.04+ \u0111\u1EC3 d\xF9ng" | |
| ); | |
| if (rc.ioWeightEffective) { | |
| out.push(" IOWeight : C\xD3 hi\u1EC7u l\u1EF1c (I/O scheduler 'bfq')"); | |
| } else { | |
| const list = rc.ioSchedulers.length > 0 ? rc.ioSchedulers.join(", ") : "kh\xF4ng d\xF2 \u0111\u01B0\u1EE3c"; | |
| out.push( | |
| ` IOWeight : KH\xD4NG c\xF3 t\xE1c d\u1EE5ng \u2014 I/O scheduler hi\u1EC7n t\u1EA1i: ${list} (IOWeight c\u1EA7n 'bfq'). | |
| Directive v\u1EABn \u0111\u01B0\u1EE3c ghi (v\xF4 h\u1EA1i) \u0111\u1EC3 c\xF3 s\u1EB5n n\u1EBFu b\u1EA1n \u0111\u1ED5i scheduler sang bfq.` | |
| ); | |
| } | |
| return out; | |
| } | |
| // src/lib/repo.ts | |
| var import_node_fs10 = require("node:fs"); | |
| var GIT_NONINTERACTIVE_ENV = { | |
| GIT_TERMINAL_PROMPT: "0", | |
| GIT_SSH_COMMAND: "ssh -o StrictHostKeyChecking=accept-new -o BatchMode=yes" | |
| }; | |
| function repoIsHttp(url) { | |
| return /^https?:\/\//i.test(url); | |
| } | |
| function repoIsSsh(url) { | |
| return /^ssh:\/\//i.test(url) || /^[^@\s]+@[^:\s]+:.+$/.test(url); | |
| } | |
| function repoHost(url) { | |
| const proto = url.match(/^[a-z]+:\/\/(?:[^@/]+@)?([^:/\s]+)/i); | |
| if (proto) return proto[1]; | |
| const scp = url.match(/^[^@\s]+@([^:\s]+):/); | |
| if (scp) return scp[1]; | |
| return ""; | |
| } | |
| var KEY_HEADER_RE = /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/; | |
| function resolveSshKeyMaterial(value) { | |
| let content; | |
| if (KEY_HEADER_RE.test(value)) { | |
| content = value; | |
| } else { | |
| if (!(0, import_node_fs10.existsSync)(value)) { | |
| die( | |
| `Kh\xF4ng t\xECm th\u1EA5y file SSH deploy key: '${value}'. | |
| H\xE3y nh\u1EADp \u0110\u01AF\u1EDCNG D\u1EAAN t\u1EDBi file key, HO\u1EB6C d\xE1n tr\u1EF1c ti\u1EBFp n\u1ED9i dung key | |
| (b\u1EAFt \u0111\u1EA7u b\u1EB1ng '-----BEGIN ... PRIVATE KEY-----').` | |
| ); | |
| } | |
| try { | |
| content = (0, import_node_fs10.readFileSync)(value, "utf8"); | |
| } catch (e) { | |
| die(`Kh\xF4ng \u0111\u1ECDc \u0111\u01B0\u1EE3c SSH key '${value}': ${e.message}`); | |
| } | |
| if (!KEY_HEADER_RE.test(content)) { | |
| die(`File '${value}' kh\xF4ng gi\u1ED1ng SSH private key (thi\u1EBFu d\xF2ng '-----BEGIN ... PRIVATE KEY-----').`); | |
| } | |
| } | |
| if (!/-----END [A-Z0-9 ]*PRIVATE KEY-----/.test(content)) { | |
| die("SSH deploy key thi\u1EBFu d\xF2ng k\u1EBFt '-----END ... PRIVATE KEY-----' \u2014 n\u1ED9i dung key c\xF3 v\u1EBB b\u1ECB c\u1EAFt c\u1EE5t."); | |
| } | |
| return content.trim() + "\n"; | |
| } | |
| function prepareRepoAuth(opts) { | |
| if ((opts.token || opts.sshKey) && !opts.repo) { | |
| die("--token/--ssh-key ch\u1EC9 d\xF9ng k\xE8m --repo (d\xF9ng \u0111\u1EC3 clone repo private)."); | |
| } | |
| if (opts.token && opts.sshKey) { | |
| die("Ch\u1EC9 ch\u1ECDn M\u1ED8T c\xE1ch x\xE1c th\u1EF1c: --token (HTTPS) HO\u1EB6C --ssh-key (SSH), kh\xF4ng d\xF9ng c\u1EA3 hai."); | |
| } | |
| if (opts.token) { | |
| if (!repoIsHttp(opts.repo)) die("--token d\xF9ng cho repo HTTPS (https://...). Repo SSH th\xEC d\xF9ng --ssh-key."); | |
| if (/[\s\x00-\x1f]/.test(opts.token)) die("Token ch\u1EE9a kho\u1EA3ng tr\u1EAFng/k\xFD t\u1EF1 \u0111i\u1EC1u khi\u1EC3n kh\xF4ng h\u1EE3p l\u1EC7."); | |
| } | |
| if (opts.sshKey) { | |
| if (!repoIsSsh(opts.repo)) die("--ssh-key d\xF9ng cho repo SSH (git@host:... ho\u1EB7c ssh://...). Repo HTTPS th\xEC d\xF9ng --token."); | |
| opts.sshKey = resolveSshKeyMaterial(opts.sshKey); | |
| } | |
| } | |
| function setupRepoAuth(user, repo, auth) { | |
| const home = `/home/${user}`; | |
| if (auth.token) { | |
| const host = repoHost(repo) || "github.com"; | |
| writeFile(`${home}/.git-credentials`, `https://x-access-token:${auth.token}@${host} | |
| `, 384); | |
| runCmd("chown", [`${user}:${user}`, `${home}/.git-credentials`]); | |
| runAs(user, "git", ["config", "--global", "credential.helper", "store"]); | |
| ok("\u0110\xE3 l\u01B0u token \u0111\u1EC3 clone repo private qua HTTPS (ch\u1EC9 user ch\u1EA1y ch\u01B0\u01A1ng tr\xECnh \u0111\u1ECDc \u0111\u01B0\u1EE3c)."); | |
| } else if (auth.sshKey) { | |
| const sshDir = `${home}/.ssh`; | |
| const keyPath = `${sshDir}/napp_deploy`; | |
| const host = repoHost(repo); | |
| ensureDir(sshDir, 448); | |
| writeFile(keyPath, auth.sshKey, 384); | |
| writeFile( | |
| `${sshDir}/config`, | |
| [ | |
| host ? `Host ${host}` : "Host *", | |
| ` IdentityFile ${keyPath}`, | |
| " IdentitiesOnly yes", | |
| " StrictHostKeyChecking accept-new", | |
| "" | |
| ].join("\n"), | |
| 384 | |
| ); | |
| runCmd("chown", ["-R", `${user}:${user}`, sshDir]); | |
| ok("\u0110\xE3 c\xE0i deploy key \u0111\u1EC3 clone repo private qua SSH (ch\u1EC9 user ch\u1EA1y ch\u01B0\u01A1ng tr\xECnh \u0111\u1ECDc \u0111\u01B0\u1EE3c)."); | |
| } | |
| } | |
| // src/lib/framework.ts | |
| var import_node_fs11 = require("node:fs"); | |
| var RULES = [ | |
| { | |
| framework: "SvelteKit (adapter-node)", | |
| probe: "build/client/_app", | |
| root: "build/client", | |
| prefixes: ["/_app/"] | |
| }, | |
| { | |
| // Next.js là trường hợp DUY NHẤT trong bảng này không dùng được `root`. | |
| // | |
| // Trên đĩa asset nằm ở '.next/static/…' nhưng URL lại là '/_next/static/…' — | |
| // tên thư mục và đoạn URL KHÁC NHAU. nginx `root` chỉ nối thẳng URI vào sau | |
| // root, nên 'root .next;' + '/_next/static/x.js' đi tìm '.next/_next/static/x.js', | |
| // một đường dẫn không bao giờ tồn tại -> toàn bộ JS/CSS trả 404 và trang | |
| // trắng. Phải dùng `alias`, nó THAY THẾ phần tiền tố đã khớp bằng thư mục. | |
| framework: "Next.js", | |
| probe: ".next/static", | |
| aliases: [{ prefix: "/_next/static/", dir: ".next/static" }], | |
| // Bẫy đắt nhất của Next.js: KHÔNG được lấy '/_next/' làm tiền tố. | |
| note: "CH\u1EC8 chi\u1EBFm '/_next/static/'. Ph\u1EA7n c\xF2n l\u1EA1i c\u1EE7a '/_next/' PH\u1EA2I \u0111i qua Node: '/_next/image' l\xE0 b\u1ED9 t\u1ED1i \u01B0u \u1EA3nh ch\u1EA1y l\xFAc request, v\xE0 '/_next/data' l\xE0 payload c\u1EE7a navigation ph\xEDa client. Ch\u1EB7n ch\xFAng b\u1EB1ng nginx l\xE0 m\u1EA5t t\u1ED1i \u01B0u \u1EA3nh v\xE0 h\u1ECFng \u0111i\u1EC1u h\u01B0\u1EDBng." | |
| }, | |
| { | |
| framework: "Nuxt 3 / Nitro", | |
| probe: ".output/public/_nuxt", | |
| root: ".output/public", | |
| prefixes: ["/_nuxt/"] | |
| }, | |
| { | |
| framework: "SolidStart / Vinxi (Nitro)", | |
| probe: ".output/public/_build", | |
| root: ".output/public", | |
| prefixes: ["/_build/"] | |
| }, | |
| { | |
| framework: "Astro (SSR)", | |
| probe: "dist/client/_astro", | |
| root: "dist/client", | |
| prefixes: ["/_astro/"] | |
| }, | |
| { | |
| framework: "Astro (static)", | |
| probe: "dist/_astro", | |
| root: "dist", | |
| prefixes: ["/_astro/"] | |
| }, | |
| { | |
| framework: "Remix / React Router v7", | |
| probe: "build/client/assets", | |
| root: "build/client", | |
| prefixes: ["/assets/"], | |
| risky: true, | |
| note: "'/assets/' KH\xD4NG ph\u1EA3i namespace ri\xEAng c\u1EE7a framework \u2014 app ho\xE0n to\xE0n c\xF3 th\u1EC3 c\xF3 route th\u1EADt \u1EDF \u0111\xF3. Ki\u1EC3m tra app kh\xF4ng d\xF9ng '/assets/...' l\xE0m route r\u1ED3i h\xE3y \xE1p." | |
| }, | |
| { | |
| framework: "Vite (SPA)", | |
| probe: "dist/assets", | |
| root: "dist", | |
| prefixes: ["/assets/"], | |
| risky: true, | |
| note: "'/assets/' KH\xD4NG ph\u1EA3i namespace ri\xEAng c\u1EE7a framework \u2014 app ho\xE0n to\xE0n c\xF3 th\u1EC3 c\xF3 route th\u1EADt \u1EDF \u0111\xF3. Ki\u1EC3m tra app kh\xF4ng d\xF9ng '/assets/...' l\xE0m route r\u1ED3i h\xE3y \xE1p." | |
| } | |
| ]; | |
| function isDir(path) { | |
| try { | |
| return (0, import_node_fs11.existsSync)(path) && (0, import_node_fs11.statSync)(path).isDirectory(); | |
| } catch { | |
| return false; | |
| } | |
| } | |
| function join(root, rel) { | |
| return `${root.replace(/\/+$/, "")}/${rel}`; | |
| } | |
| function detectStaticLayout(appRoot) { | |
| if (!isDir(appRoot)) return null; | |
| for (const rule of RULES) { | |
| const probe = join(appRoot, rule.probe); | |
| if (!isDir(probe)) continue; | |
| return { | |
| framework: rule.framework, | |
| evidence: probe, | |
| staticRoot: rule.root ? join(appRoot, rule.root) : void 0, | |
| staticPrefixes: rule.prefixes ?? [], | |
| staticAliases: (rule.aliases ?? []).map((a) => ({ prefix: a.prefix, dir: join(appRoot, a.dir) })), | |
| risky: rule.risky ?? false, | |
| note: rule.note | |
| }; | |
| } | |
| return null; | |
| } | |
| var PUBLIC_ROOTS = ["static", "public"]; | |
| var UPLOAD_DIRS = ["uploads", "upload"]; | |
| function detectUploadDir(appRoot) { | |
| if (!isDir(appRoot)) return null; | |
| for (const root of PUBLIC_ROOTS) { | |
| if (!isDir(join(appRoot, root))) continue; | |
| for (const name of UPLOAD_DIRS) { | |
| const dir = join(appRoot, `${root}/${name}`); | |
| if (isDir(dir)) return { dir, prefix: `/${name}/`, publicRoot: root }; | |
| } | |
| } | |
| return null; | |
| } | |
| function staticFlags(s) { | |
| const out = []; | |
| if (s.staticRoot) out.push("--static-root", s.staticRoot); | |
| for (const p of s.staticPrefixes) out.push("--static-prefix", p); | |
| for (const a of s.staticAliases) out.push("--static-alias", `${a.prefix}=${a.dir}`); | |
| return out; | |
| } | |
| function staticSetCommand(domain2, s) { | |
| return `sudo napp app set ${domain2} ${staticFlags(s).join(" ")}`; | |
| } | |
| function parseStaticAlias(item) { | |
| const eq = item.indexOf("="); | |
| if (eq === -1) return { error: `--static-alias ph\u1EA3i theo d\u1EA1ng <ti\u1EC1n-t\u1ED1-URL>=<th\u01B0-m\u1EE5c>, nh\u1EADn \u0111\u01B0\u1EE3c: '${item}'` }; | |
| const prefix = item.slice(0, eq).trim(); | |
| const dir = item.slice(eq + 1).trim(); | |
| if (!prefix.startsWith("/")) return { error: `--static-alias: ti\u1EC1n t\u1ED1 URL ph\u1EA3i b\u1EAFt \u0111\u1EA7u b\u1EB1ng '/', nh\u1EADn \u0111\u01B0\u1EE3c: '${prefix}'` }; | |
| if (!prefix.endsWith("/")) return { error: `--static-alias: ti\u1EC1n t\u1ED1 URL ph\u1EA3i k\u1EBFt th\xFAc b\u1EB1ng '/', nh\u1EADn \u0111\u01B0\u1EE3c: '${prefix}'` }; | |
| if (prefix.includes("..") || dir.includes("..")) return { error: `--static-alias: kh\xF4ng cho ph\xE9p '..' trong '${item}'` }; | |
| if (!dir.startsWith("/")) return { error: `--static-alias: th\u01B0 m\u1EE5c ph\u1EA3i l\xE0 \u0111\u01B0\u1EDDng d\u1EABn TUY\u1EC6T \u0110\u1ED0I, nh\u1EADn \u0111\u01B0\u1EE3c: '${dir}'` }; | |
| return { value: { prefix, dir: dir.replace(/\/+$/, "") } }; | |
| } | |
| // src/lib/staticaccess.ts | |
| var import_node_fs12 = require("node:fs"); | |
| var DEFAULT_NGINX_USER = "www-data"; | |
| function nginxWorkerUser() { | |
| const conf = "/etc/nginx/nginx.conf"; | |
| if (!(0, import_node_fs12.existsSync)(conf)) return DEFAULT_NGINX_USER; | |
| try { | |
| const m = (0, import_node_fs12.readFileSync)(conf, "utf8").match(/^\s*user\s+([A-Za-z0-9._-]+)\s*;?/m); | |
| return m?.[1] ?? DEFAULT_NGINX_USER; | |
| } catch { | |
| return DEFAULT_NGINX_USER; | |
| } | |
| } | |
| function pathReadableBy(user, path) { | |
| if (process.getuid && process.getuid() !== 0) return null; | |
| if (!commandExists("sudo")) return null; | |
| if (execCapture("id", [user]).code !== 0) return null; | |
| if (execCapture("sudo", ["-n", "-u", user, "test", "-r", "/", "-a", "-x", "/"]).code !== 0) return null; | |
| return execCapture("sudo", ["-n", "-u", user, "test", "-r", path, "-a", "-x", path]).code === 0; | |
| } | |
| function nginxInGroup(group) { | |
| const nginxUser = nginxWorkerUser(); | |
| const res = execCapture("id", ["-nG", nginxUser]); | |
| if (res.code !== 0) return null; | |
| return res.stdout.trim().split(/\s+/).includes(group); | |
| } | |
| function grantNginxGroupAccess(appGroup) { | |
| const nginxUser = nginxWorkerUser(); | |
| const already = nginxInGroup(appGroup); | |
| if (already === true) return { changed: false, message: `${nginxUser} \u0111\xE3 thu\u1ED9c nh\xF3m ${appGroup}.` }; | |
| if (already === null) return { changed: false, message: `Kh\xF4ng ki\u1EC3m tra \u0111\u01B0\u1EE3c nh\xF3m c\u1EE7a '${nginxUser}' \u2014 b\u1ECF qua.` }; | |
| const add = runCmd("usermod", ["-aG", appGroup, nginxUser], { silentFail: true }); | |
| if (add.code !== 0) return { changed: false, message: `Kh\xF4ng th\xEAm \u0111\u01B0\u1EE3c '${nginxUser}' v\xE0o nh\xF3m '${appGroup}' (m\xE3 ${add.code}).` }; | |
| info(`Restart nginx \u0111\u1EC3 n\u1EA1p quy\u1EC1n nh\xF3m m\u1EDBi (reload KH\xD4NG \u0111\u1EE7 \u2014 danh s\xE1ch nh\xF3m ch\u1EC9 \u0111\u1ECDc l\xFAc ti\u1EBFn tr\xECnh kh\u1EDFi t\u1EA1o).`); | |
| const restart = runCmd("systemctl", ["restart", "nginx"], { silentFail: true }); | |
| if (restart.code !== 0) { | |
| return { changed: true, message: `\u0110\xE3 th\xEAm '${nginxUser}' v\xE0o nh\xF3m '${appGroup}' nh\u01B0ng RESTART NGINX TH\u1EA4T B\u1EA0I \u2014 quy\u1EC1n m\u1EDBi ch\u01B0a c\xF3 hi\u1EC7u l\u1EF1c. Ch\u1EA1y: sudo systemctl restart nginx` }; | |
| } | |
| return { changed: true, message: `\u0110\xE3 th\xEAm '${nginxUser}' v\xE0o nh\xF3m '${appGroup}' v\xE0 restart nginx.` }; | |
| } | |
| function appServePaths(app2) { | |
| return [app2.staticRoot, ...(app2.staticAliases ?? []).map((a) => a.dir), app2.uploadDir].filter((p) => !!p); | |
| } | |
| function ensureNginxCanServe(appGroup, paths) { | |
| const dirs = paths.filter((p) => p && (0, import_node_fs12.existsSync)(p)); | |
| if (dirs.length === 0) return; | |
| const nginxUser = nginxWorkerUser(); | |
| const unreadable = dirs.filter((d) => pathReadableBy(nginxUser, d) === false); | |
| if (unreadable.length === 0) return; | |
| warn( | |
| `nginx (user '${nginxUser}') KH\xD4NG \u0111\u1ECDc \u0111\u01B0\u1EE3c ${unreadable.length} th\u01B0 m\u1EE5c v\u1EEBa khai b\xE1o \u2014 n\u1EBFu \u0111\u1EC3 nguy\xEAn, asset t\u0129nh s\u1EBD tr\u1EA3 403 ch\u1EE9 kh\xF4ng ph\u1EA3i file: | |
| ` + unreadable.map((d) => ` ${d}`).join("\n") | |
| ); | |
| const res = grantNginxGroupAccess(appGroup); | |
| if (res.changed) { | |
| ok(res.message); | |
| info(`nginx nay \u0111\u1ECDc \u0111\u01B0\u1EE3c c\xE2y m\xE3 ngu\u1ED3n c\u1EE7a app \u1EDF M\u1EE8C NH\xD3M. '.env' v\u1EABn an to\xE0n (chmod 600, ch\u1EC9 ch\u1EE7 s\u1EDF h\u1EEFu).`); | |
| } else { | |
| warn(`${res.message} Asset t\u0129nh c\xF3 th\u1EC3 v\u1EABn tr\u1EA3 403 \u2014 ki\u1EC3m tra b\u1EB1ng: sudo -u ${nginxUser} test -r ${unreadable[0]} && echo OK`); | |
| } | |
| } | |
| // src/lib/provision.ts | |
| function defaultPackageManager(runtime) { | |
| return runtime === "bun" ? "bun" : "npm"; | |
| } | |
| function defaultInstallCmd(pm) { | |
| switch (pm) { | |
| case "bun": | |
| return "if [ -f bun.lockb ] || [ -f bun.lock ]; then bun install --production --frozen-lockfile || bun install --production --no-frozen-lockfile; else bun install --production --no-frozen-lockfile; fi"; | |
| case "pnpm": | |
| return "if [ -f pnpm-lock.yaml ]; then pnpm install --prod --frozen-lockfile || pnpm install --prod; else pnpm install --prod; fi"; | |
| case "yarn": | |
| return "if [ -f yarn.lock ]; then yarn install --production --frozen-lockfile || yarn install --immutable || yarn install; else yarn install --production || yarn install; fi"; | |
| case "npm": | |
| default: | |
| return "if [ -f package-lock.json ] || [ -f npm-shrinkwrap.json ]; then npm ci --omit=dev || npm install --omit=dev; else npm install --omit=dev; fi"; | |
| } | |
| } | |
| function defaultStartCmd(runtime, pm) { | |
| if (runtime === "bun") return "bun run start"; | |
| return pm === "bun" ? "npm start" : `${pm} start`; | |
| } | |
| function commandExistsSystemWide(cmd) { | |
| const res = execCapture("bash", ["-lc", `p="$(command -v ${cmd} 2>/dev/null)" && readlink -f "$p"`]); | |
| if (res.code !== 0) return false; | |
| return /^\/(usr|opt|bin|sbin)\//.test(res.stdout.trim()); | |
| } | |
| function ensurePackageManager(pm) { | |
| if (pm === "npm") return; | |
| if (commandExistsSystemWide(pm)) return; | |
| if (pm === "bun") { | |
| die( | |
| "bun ch\u01B0a \u0111\u01B0\u1EE3c c\xE0i \u1EDF m\u1EE9c h\u1EC7 th\u1ED1ng. C\xE0i l\u1EA1i b\u1EB1ng install.sh (m\u1EB7c \u0111\u1ECBnh c\xF3 c\xE0i bun), ho\u1EB7c:\n sudo bash -c 'export BUN_INSTALL=/usr/local; curl -fsSL https://bun.sh/install | bash'\nr\u1ED3i th\u1EED l\u1EA1i \u2014 ho\u1EB7c ch\u1ECDn package manager kh\xE1c." | |
| ); | |
| } | |
| info(`'${pm}' ch\u01B0a c\xF3 \u1EDF m\u1EE9c h\u1EC7 th\u1ED1ng \u2014 \u0111ang c\xE0i global b\u1EB1ng 'npm install -g ${pm}'...`); | |
| runCmd("npm", ["install", "-g", pm]); | |
| if (!commandExistsSystemWide(pm)) { | |
| die(`\u0110\xE3 ch\u1EA1y 'npm install -g ${pm}' nh\u01B0ng '${pm}' v\u1EABn ch\u01B0a d\xF9ng \u0111\u01B0\u1EE3c \u1EDF m\u1EE9c h\u1EC7 th\u1ED1ng. H\xE3y c\xE0i '${pm}' th\u1EE7 c\xF4ng r\u1ED3i th\u1EED l\u1EA1i.`); | |
| } | |
| ok(`\u0110\xE3 c\xE0i '${pm}' \u1EDF m\u1EE9c h\u1EC7 th\u1ED1ng.`); | |
| } | |
| function ensureRuntime(runtime) { | |
| if (runtime === "node" && !commandExists("node")) { | |
| die("Node.js ch\u01B0a \u0111\u01B0\u1EE3c c\xE0i. Ch\u1EA1y 'napp check --fix' tr\u01B0\u1EDBc."); | |
| } | |
| if (runtime === "bun" && !commandExistsSystemWide("bun")) { | |
| die( | |
| "bun ch\u01B0a \u0111\u01B0\u1EE3c c\xE0i \u1EDF m\u1EE9c h\u1EC7 th\u1ED1ng (runtime=bun c\u1EA7n bun \u0111\u1EC3 ch\u1EA1y). C\xE0i l\u1EA1i b\u1EB1ng install.sh\n (m\u1EB7c \u0111\u1ECBnh c\xF3 c\xE0i bun), ho\u1EB7c: sudo bash -c 'export BUN_INSTALL=/usr/local; curl -fsSL https://bun.sh/install | bash'\nr\u1ED3i th\u1EED l\u1EA1i \u2014 ho\u1EB7c ch\u1ECDn runtime node." | |
| ); | |
| } | |
| } | |
| // src/commands/app.ts | |
| function resolveStaticAliases(items) { | |
| const out = []; | |
| for (const item of items ?? []) { | |
| const { value, error } = parseStaticAlias(item); | |
| if (error) die(error); | |
| out.push(value); | |
| } | |
| return out; | |
| } | |
| function suggestedPrefixes(s) { | |
| return [...s.staticPrefixes, ...s.staticAliases.map((a) => a.prefix)]; | |
| } | |
| function reportStaticDetection(domain2, s, applied, autoStatic) { | |
| const prefixes = suggestedPrefixes(s).join(" "); | |
| if (applied) { | |
| ok(`Nh\u1EADn di\u1EC7n ${s.framework} \u2192 nginx tr\u1EA3 th\u1EB3ng ${prefixes} (kh\xF4ng qua Node).`); | |
| info(` C\u0103n c\u1EE9: c\xF3 th\u01B0 m\u1EE5c ${s.evidence}`); | |
| if (s.note) warn(` L\u01AFU \xDD: ${s.note}`); | |
| return; | |
| } | |
| info(`Nh\u1EADn di\u1EC7n ${s.framework} (c\u0103n c\u1EE9: c\xF3 th\u01B0 m\u1EE5c ${s.evidence}).`); | |
| if (s.risky) { | |
| warn( | |
| `napp KH\xD4NG t\u1EF1 \xE1p c\u1EA5u h\xECnh n\xE0y${autoStatic ? " d\xF9 b\u1EA1n \u0111\xE3 truy\u1EC1n --auto-static" : ""}: ti\u1EC1n t\u1ED1 '${prefixes}' kh\xF4ng ph\u1EA3i namespace ri\xEAng c\u1EE7a framework. | |
| 'location ^~' th\u1EAFng c\u1EA3 route regex l\u1EABn proxy_pass, n\xEAn n\u1EBFu app c\xF3 route th\u1EADt \u1EDF \u0111\xF3 th\xEC route \u1EA5y ch\u1EBFt h\u1EB3n b\u1EB1ng 404.` | |
| ); | |
| } else if (!autoStatic) { | |
| info(` Hi\u1EC7n M\u1ECCI file .js/.css/.woff2 \u0111\u1EC1u \u0111i qua ti\u1EBFn tr\xECnh Node \u2014 th\xEAm --auto-static l\xFAc t\u1EA1o app \u0111\u1EC3 napp t\u1EF1 b\u1EADt.`); | |
| } | |
| info(` B\u1EADt b\u1EB1ng: ${staticSetCommand(domain2, s)}`); | |
| if (s.note) warn(` L\u01AFU \xDD: ${s.note}`); | |
| } | |
| function reportHotlink(app2) { | |
| const allow = app2.hotlinkAllow ?? []; | |
| if (allow.length === 0) { | |
| ok(`\u2022 Ch\u1EB7n hotlink: Cross-Origin-Resource-Policy=same-site (tr\xECnh duy\u1EC7t th\u1EF1c thi, trang nh\xFAng KH\xD4NG l\xE1ch \u0111\u01B0\u1EE3c) + ki\u1EC3m tra Referer.`); | |
| } else { | |
| warn( | |
| `\u2022 Ch\u1EB7n hotlink: CH\u1EC8 c\xF2n ki\u1EC3m tra Referer, KH\xD4NG c\xF3 CORP. | |
| V\xEC --hotlink-allow \u0111ang cho ph\xE9p domain ngo\xE0i (${allow.join(", ")}), m\xE0 CORP ch\u1EC9 c\xF3 same-origin/same-site/cross-origin \u2014 | |
| kh\xF4ng di\u1EC5n \u0111\u1EA1t \u0111\u01B0\u1EE3c danh s\xE1ch cho ph\xE9p. B\u1EADt CORP \u1EDF \u0111\xE2y s\u1EBD ch\u1EB7n \u0111\xFAng c\xE1c domain b\u1EA1n v\u1EEBa cho ph\xE9p. | |
| Referer do CH\xCDNH trang nh\xFAng khai b\xE1o: m\u1ED9t th\u1EBB <meta name="referrer" content="no-referrer"> l\xE0 \u0111i qua. | |
| C\u1EA7n ch\u1EB7n th\u1EADt m\xE0 v\u1EABn cho \u0111\u1ED1i t\xE1c nh\xFAng: d\xF9ng URL k\xFD (nginx secure_link) ho\u1EB7c b\u1EADt \u1EDF t\u1EA7ng CDN.` | |
| ); | |
| } | |
| if (app2.hotlinkStrict) { | |
| warn(` --hotlink-strict \u0111ang B\u1EACT: link chia s\u1EBB (Facebook, Zalo, Telegram) s\u1EBD M\u1EA4T \u1EA3nh preview, v\xE0 ng\u01B0\u1EDDi d\xF9ng sau proxy c\xF4ng ty c\xF3 th\u1EC3 b\u1ECB 403.`); | |
| } | |
| } | |
| function nodeOptionsFor(rec, heapMB) { | |
| if (rec.nodeRuntime !== "node") return void 0; | |
| const flags = [`--max-old-space-size=${heapMB}`]; | |
| if (rec.leakGuard) flags.push("--heapsnapshot-signal=SIGUSR2", "--heapsnapshot-near-heap-limit=1"); | |
| return flags.join(" "); | |
| } | |
| function writeAppUnit(app2, heapMB, authoritative = []) { | |
| const nodeOptions = nodeOptionsFor(app2, heapMB); | |
| const path = `${SYSTEMD_DIR}/${serviceNameFor(app2.domain)}.service`; | |
| reportUnitWrite(path, writeManagedUnit(path, renderAppSystemdService(app2, execStartLine(app2.startCmd), { nodeOptions }), { authoritative })); | |
| } | |
| function writeServiceUnit(svc, heapMB, authoritative = []) { | |
| const nodeOptions = nodeOptionsFor(svc, heapMB); | |
| const path = `${SYSTEMD_DIR}/${svcSystemdName(svc.name)}.service`; | |
| const memoryHighMB = detectResourceControl().cgroupV2 ? serviceMemoryHighMB(heapMB) : void 0; | |
| reportUnitWrite( | |
| path, | |
| writeManagedUnit(path, renderServiceSystemdService(svc, execStartLine(svc.startCmd), { nodeOptions, memoryHighMB }), { authoritative }) | |
| ); | |
| } | |
| function reportUnitWrite(path, res) { | |
| if (res.preserved.length > 0) info(`${path}: gi\u1EEF nguy\xEAn directive b\u1EA1n \u0111\xE3 s\u1EEDa (${res.preserved.join(", ")}).`); | |
| if (res.overridden.length > 0) { | |
| warn( | |
| `${path}: napp bu\u1ED9c ph\u1EA3i \u0111\u1EB7t l\u1EA1i ${res.overridden.join(", ")} theo c\u1EA5u h\xECnh m\u1EDBi trong registry \u2014 b\u1EA3n s\u1EEDa tay c\u1EE7a b\u1EA1n \u1EDF c\xE1c directive n\xE0y KH\xD4NG c\xF2n.` | |
| ); | |
| } | |
| } | |
| function unitMix(delta = {}) { | |
| const s = loadState(); | |
| return { | |
| webApps: Object.keys(s.apps).length + (delta.webApps ?? 0), | |
| services: Object.keys(s.services).length + (delta.services ?? 0) | |
| }; | |
| } | |
| function currentHeapPlan(delta = {}) { | |
| const mix = unitMix(delta); | |
| return nodeHeapPlan(detectHardware(), mix, { serviceWeight: loadState().serviceHeapWeight }); | |
| } | |
| function priorityDirectivesFor(kind, heapMB) { | |
| if (kind === "web") return { CPUWeight: String(CPU_WEIGHT_WEB), IOWeight: String(IO_WEIGHT_WEB) }; | |
| const out = { CPUWeight: String(CPU_WEIGHT_SERVICE), IOWeight: String(IO_WEIGHT_SERVICE) }; | |
| if (detectResourceControl().cgroupV2) out.MemoryHigh = `${serviceMemoryHighMB(heapMB)}M`; | |
| return out; | |
| } | |
| function reportBalance(r) { | |
| info( | |
| `Ng\xE2n s\xE1ch RAM cho node: web app ${r.webMB} MB \xB7 background service ${r.serviceMB} MB (tr\u1ECDng s\u1ED1 service ${r.serviceWeight}) \u2014 ${r.totalUnits} \u0111\u01A1n v\u1ECB.` | |
| ); | |
| if (r.heapChanged.length > 0) info(` \u0110\xE3 restart \u0111\u1EC3 \xE1p heap m\u1EDBi: ${r.heapChanged.join(", ")}`); | |
| if (r.priorityChanged.length > 0) { | |
| info(` \u0110\xE3 c\u1EADp nh\u1EADt \u01B0u ti\xEAn CPU/IO (daemon-reload \xE1p ngay, KH\xD4NG c\u1EA7n restart): ${r.priorityChanged.join(", ")}`); | |
| } | |
| } | |
| function applyNodeHeaps(opts = { restart: false }) { | |
| const s = loadState(); | |
| const apps = Object.values(s.apps); | |
| const services = Object.values(s.services); | |
| const plan = nodeHeapPlan(detectHardware(), { webApps: apps.length, services: services.length }, { serviceWeight: s.serviceHeapWeight }); | |
| const heapChanged = []; | |
| const priorityChanged = []; | |
| if (apps.length + services.length === 0) return { ...plan, heapChanged, priorityChanged }; | |
| const sync = (unit, id, kind, heapMB, isNode, create) => { | |
| const path = `${SYSTEMD_DIR}/${unit}.service`; | |
| const skip = id === opts.skipRestartFor; | |
| if (isNode) { | |
| if (patchHeapOrCreate(path, heapMB, create) && !skip) heapChanged.push(unit); | |
| } else if (!(0, import_node_fs13.existsSync)(path)) { | |
| warn(`Kh\xF4ng t\xECm th\u1EA5y ${path} \u2014 d\u1EF1ng l\u1EA1i unit t\u1EEB registry.`); | |
| create(); | |
| if (!skip) heapChanged.push(unit); | |
| } | |
| const pr = patchUnitPriority(path, priorityDirectivesFor(kind, heapMB)); | |
| if (pr.preserved.length > 0) info(`${path}: gi\u1EEF nguy\xEAn ${pr.preserved.join(", ")} b\u1EA1n \u0111\xE3 kho\xE1 b\u1EB1ng '# napp-preserve:'.`); | |
| if (pr.changed && !heapChanged.includes(unit) && !skip) priorityChanged.push(unit); | |
| }; | |
| for (const app2 of apps) { | |
| sync(serviceNameFor(app2.domain), app2.domain, "web", plan.webMB, app2.nodeRuntime === "node", () => writeAppUnit(app2, plan.webMB)); | |
| } | |
| for (const svc of services) { | |
| sync(svcSystemdName(svc.name), svc.name, "service", plan.serviceMB, svc.nodeRuntime === "node", () => writeServiceUnit(svc, plan.serviceMB)); | |
| } | |
| if (heapChanged.length + priorityChanged.length === 0) return { ...plan, heapChanged, priorityChanged }; | |
| runCmd("systemctl", ["daemon-reload"]); | |
| if (opts.restart) { | |
| for (const unit of heapChanged) runCmd("systemctl", ["restart", unit], { silentFail: true }); | |
| } | |
| return { ...plan, heapChanged, priorityChanged }; | |
| } | |
| function syncAllUnits(opts) { | |
| const s = loadState(); | |
| const apps = Object.values(s.apps); | |
| const services = Object.values(s.services); | |
| const plan = nodeHeapPlan(detectHardware(), { webApps: apps.length, services: services.length }, { serviceWeight: s.serviceHeapWeight }); | |
| if (apps.length + services.length === 0) return plan; | |
| for (const app2 of apps) writeAppUnit(app2, plan.webMB); | |
| for (const svc of services) writeServiceUnit(svc, plan.serviceMB); | |
| runCmd("systemctl", ["daemon-reload"]); | |
| if (opts.restart) { | |
| for (const app2 of apps) runCmd("systemctl", ["restart", serviceNameFor(app2.domain)], { silentFail: true }); | |
| for (const svc of services) runCmd("systemctl", ["restart", svcSystemdName(svc.name)], { silentFail: true }); | |
| } | |
| return plan; | |
| } | |
| function patchHeapOrCreate(path, heapMB, create) { | |
| if (!(0, import_node_fs13.existsSync)(path)) { | |
| warn(`Kh\xF4ng t\xECm th\u1EA5y ${path} \u2014 d\u1EF1ng l\u1EA1i unit t\u1EEB registry.`); | |
| create(); | |
| return true; | |
| } | |
| const res = patchUnitHeap(path, heapMB); | |
| if (res.note) warn(`${path}: ${res.note}`); | |
| return res.changed; | |
| } | |
| function assertSiteAbsent(domain2, user, port) { | |
| const conflicts = []; | |
| const webRoot = `${WWW_ROOT}/${domain2}`; | |
| if ((0, import_node_fs13.existsSync)(webRoot)) conflicts.push(`th\u01B0 m\u1EE5c m\xE3 ngu\u1ED3n: ${webRoot}`); | |
| const ngxConf = `${NGINX_AVAILABLE}/${domain2}.conf`; | |
| if ((0, import_node_fs13.existsSync)(ngxConf)) conflicts.push(`c\u1EA5u h\xECnh nginx: ${ngxConf}`); | |
| const ngxLink = `${NGINX_ENABLED}/${domain2}.conf`; | |
| if ((0, import_node_fs13.existsSync)(ngxLink)) conflicts.push(`symlink nginx: ${ngxLink}`); | |
| const svc = `${SYSTEMD_DIR}/${serviceNameFor(domain2)}.service`; | |
| if ((0, import_node_fs13.existsSync)(svc)) conflicts.push(`systemd unit: ${svc}`); | |
| if (execCapture("id", [user]).code === 0) conflicts.push(`user h\u1EC7 th\u1ED1ng: ${user}`); | |
| if (getApp(domain2)) conflicts.push(`registry: \u0111\xE3 c\xF3 trong ${`/etc/napp/state.json`}`); | |
| if (conflicts.length > 0) { | |
| die( | |
| `Website '${domain2}' (ho\u1EB7c t\xE0i nguy\xEAn c\xF9ng t\xEAn) \u0110\xC3 T\u1ED2N T\u1EA0I \u2014 kh\xF4ng t\u1EA1o tr\xF9ng. | |
| ` + conflicts.map((c) => ` - ${c}`).join("\n") + ` | |
| Mu\u1ED1n t\u1EA1o l\u1EA1i? H\xE3y xo\xE1 tr\u01B0\u1EDBc b\u1EB1ng: napp app remove ${domain2}` | |
| ); | |
| } | |
| } | |
| async function cmdAppCreate(domain2, opts) { | |
| requireRoot(); | |
| validateDomain(domain2); | |
| validateBranch(opts.branch); | |
| if (opts.repo) validateRepoUrl(opts.repo); | |
| prepareRepoAuth(opts); | |
| const user = userFor(domain2); | |
| const webRoot = `${WWW_ROOT}/${domain2}`; | |
| const port = allocatePort(opts.port); | |
| validatePort(port); | |
| const serviceName = serviceNameFor(domain2); | |
| assertSiteAbsent(domain2, user, port); | |
| const pm = opts.packageManager ?? defaultPackageManager(opts.runtime); | |
| ensureRuntime(opts.runtime); | |
| ensurePackageManager(pm); | |
| if (!commandExists("nginx")) die("nginx ch\u01B0a \u0111\u01B0\u1EE3c c\xE0i. Ch\u1EA1y 'napp check --fix' tr\u01B0\u1EDBc."); | |
| const release = acquireLock(domain2); | |
| let rollbackActive = true; | |
| let dbCreatedName; | |
| const rollback = () => { | |
| if (!rollbackActive) return; | |
| warn("T\u1EA1o app th\u1EA5t b\u1EA1i \u2014 \u0111ang ho\xE0n t\xE1c c\xE1c thay \u0111\u1ED5i \u0111\xE3 th\u1EF1c hi\u1EC7n..."); | |
| try { | |
| runCmd("rm", ["-f", `${NGINX_ENABLED}/${domain2}.conf`, `${NGINX_AVAILABLE}/${domain2}.conf`, appLocationsPath(domain2)], { silentFail: true }); | |
| runCmd("bash", ["-lc", "nginx -t >/dev/null 2>&1 && systemctl reload nginx || true"], { silentFail: true }); | |
| runCmd("systemctl", ["stop", serviceName], { silentFail: true }); | |
| runCmd("systemctl", ["disable", serviceName], { silentFail: true }); | |
| runCmd("rm", ["-f", `${SYSTEMD_DIR}/${serviceName}.service`], { silentFail: true }); | |
| runCmd("systemctl", ["daemon-reload"], { silentFail: true }); | |
| if ((0, import_node_fs13.existsSync)(webRoot)) (0, import_node_fs13.rmSync)(webRoot, { recursive: true, force: true }); | |
| if (execCapture("id", [user]).code === 0) { | |
| runCmd("userdel", ["-r", user], { silentFail: true }); | |
| } | |
| if (dbCreatedName) { | |
| try { | |
| dropDatabase(dbCreatedName, dbCreatedName); | |
| } catch { | |
| } | |
| } | |
| warn(`\u0110\xE3 ho\xE0n t\xE1c. H\u1EC7 th\u1ED1ng tr\u1EDF l\u1EA1i tr\u1EA1ng th\xE1i tr\u01B0\u1EDBc khi t\u1EA1o '${domain2}'.`); | |
| } finally { | |
| release(); | |
| } | |
| }; | |
| try { | |
| section(`T\u1EA1o app ${domain2}`); | |
| info(`Runtime ${opts.runtime}, qu\u1EA3n l\xFD g\xF3i ${pm}, c\u1ED5ng ${port}, user h\u1EC7 th\u1ED1ng ${user}`); | |
| runCmd("useradd", ["--system", "--create-home", "--home-dir", `/home/${user}`, "--shell", "/usr/sbin/nologin", user]); | |
| ok(`\u0110\xE3 t\u1EA1o user h\u1EC7 th\u1ED1ng ${user}`); | |
| ensureDir(webRoot); | |
| runCmd("chown", [`${user}:${user}`, webRoot]); | |
| if (opts.repo) { | |
| if (opts.token || opts.sshKey) setupRepoAuth(user, opts.repo, opts); | |
| info(`\u0110ang clone ${opts.repo} (branch ${opts.branch})...`); | |
| const clone = runAs(user, "git", ["clone", "--branch", opts.branch, "--depth", "1", opts.repo, webRoot], { | |
| env: GIT_NONINTERACTIVE_ENV, | |
| silentFail: true | |
| }); | |
| if (clone.code !== 0) { | |
| const privateHint = !opts.token && !opts.sshKey ? ` | |
| N\u1EBFu \u0111\xE2y l\xE0 repo PRIVATE: napp KH\xD4NG h\u1ECFi m\u1EADt kh\u1EA9u t\u01B0\u01A1ng t\xE1c (tr\xE1nh treo). H\xE3y th\xEAm: | |
| - Repo HTTPS: --token <Personal-Access-Token> | |
| - Repo SSH : --ssh-key <\u0111\u01B0\u1EDDng-d\u1EABn-deploy-key>` : ` | |
| Ki\u1EC3m tra l\u1EA1i token/deploy key c\xF3 quy\u1EC1n \u0111\u1ECDc repo, v\xE0 branch '${opts.branch}' t\u1ED3n t\u1EA1i.`; | |
| die(`Clone repo th\u1EA5t b\u1EA1i (m\xE3 ${clone.code}). Ki\u1EC3m tra URL/branch, m\u1EA1ng, ho\u1EB7c quy\u1EC1n truy c\u1EADp.${privateHint}`); | |
| } | |
| } else { | |
| info("Kh\xF4ng c\xF3 --repo \u2014 t\u1EA1o app m\u1EABu t\u1ED1i gi\u1EA3n \u0111\u1EC3 b\u1EA1n t\u1EF1 \u0111\u01B0a m\xE3 ngu\u1ED3n l\xEAn sau..."); | |
| runAs(user, "bash", [ | |
| "-lc", | |
| `cat > ${JSON.stringify(webRoot + "/package.json")} <<'EOF' | |
| { | |
| "name": "${domain2.replace(/[^a-z0-9-]/gi, "-")}", | |
| "version": "1.0.0", | |
| "private": true, | |
| "scripts": { "start": "node server.js" } | |
| } | |
| EOF | |
| cat > ${JSON.stringify(webRoot + "/server.js")} <<'EOF' | |
| // File t\u1EA1m do napp t\u1EA1o \u2014 h\xE3y thay b\u1EB1ng m\xE3 ngu\u1ED3n th\u1EADt c\u1EE7a b\u1EA1n. | |
| const http = require("http"); | |
| const port = process.env.PORT || ${port}; | |
| const html = \`<!DOCTYPE html> | |
| <html lang="vi"> | |
| <head> | |
| <meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>${domain2}</title> | |
| <style> | |
| html, body { height: 100%; margin: 0; } | |
| body { | |
| display: flex; | |
| justify-content: center; | |
| align-items: center; | |
| min-height: 100vh; | |
| padding: 16px; | |
| box-sizing: border-box; | |
| font-family: system-ui, -apple-system, sans-serif; | |
| text-align: center; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <p>Trang web \u0111ang trong qu\xE1 tr\xECnh ph\xE1t tri\u1EC3n. Vui l\xF2ng quay l\u1EA1i sau.</p> | |
| </body> | |
| </html>\`; | |
| http.createServer((req, res) => { | |
| res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }); | |
| res.end(html); | |
| }).listen(port, () => console.log("listening on " + port)); | |
| EOF` | |
| ]); | |
| } | |
| const installCmd = opts.installCmd ?? defaultInstallCmd(pm); | |
| const buildCmd = opts.buildCmd ?? ""; | |
| const startCmd = opts.startCmd ?? defaultStartCmd(opts.runtime, pm); | |
| if ((0, import_node_fs13.existsSync)(`${webRoot}/package.json`) || opts.repo) { | |
| info("\u0110ang c\xE0i dependencies..."); | |
| runAs(user, "bash", ["-lc", installCmd], { cwd: webRoot }); | |
| if (buildCmd) { | |
| info("\u0110ang build..."); | |
| runAs(user, "bash", ["-lc", buildCmd], { cwd: webRoot }); | |
| } | |
| } | |
| let dbInfo; | |
| if (opts.db) { | |
| dbInfo = createDatabase(user, user); | |
| dbCreatedName = dbInfo.name; | |
| ok(`\u0110\xE3 t\u1EA1o database '${dbInfo.name}' + user CSDL '${dbInfo.user}'@'localhost'`); | |
| } | |
| let redisDbIndex; | |
| if (opts.redis || opts.redisDb !== void 0 || opts.shareRedisWith) { | |
| const preferred = opts.shareRedisWith ? redisDbOf(opts.shareRedisWith) : opts.redisDb; | |
| redisDbIndex = resolveRedisDb(preferred); | |
| if (redisDbIndex === void 0) { | |
| warn("\u0110\xE3 h\u1EBFt database Redis ri\xEAng (0-15). B\u1ECF qua c\u1EA5p DB ri\xEAng \u2014 h\xE3y d\xF9ng key-prefix trong app thay v\xEC DB ri\xEAng."); | |
| } else if (preferred !== void 0) { | |
| ok(`D\xF9ng CHUNG Redis DB #${redisDbIndex}${opts.shareRedisWith ? ` v\u1EDBi '${opts.shareRedisWith}'` : ""}`); | |
| } else { | |
| ok(`\u0110\xE3 c\u1EA5p Redis DB #${redisDbIndex} cho app n\xE0y`); | |
| } | |
| } | |
| const envUpdates = { | |
| NODE_ENV: "production", | |
| PORT: String(port), | |
| APP_URL: `http://${domain2}`, | |
| // --- App chạy sau reverse proxy (nginx) --- | |
| // adapter-node của SvelteKit mặc định KHÔNG tin các header X-Forwarded-*, | |
| // nên app tưởng mình đang chạy HTTP kể cả khi người dùng vào bằng HTTPS | |
| // (nginx mới là chỗ kết thúc TLS). Hệ quả: mọi đoạn code kiểu "chưa https | |
| // thì redirect sang https" sẽ LẶP VÔ HẠN, cookie Secure và kiểm tra CSRF | |
| // cũng sai theo. Ba biến dưới đây bảo adapter-node đọc header do nginx gửi. | |
| // Framework khác không hiểu thì đơn giản là bỏ qua — vô hại. | |
| // | |
| // ĐẶC BIỆT với SvelteKit — kiểm tra CSRF: adapter-node so Origin của trình | |
| // duyệt với origin server tự suy ra và CHẶN mọi POST/form action bằng lỗi | |
| // 403 "Cross-site POST form submissions are forbidden" nếu hai bên lệch. | |
| // Sau proxy server chỉ thấy http://127.0.0.1:<port> nên rất dễ lệch; cặp | |
| // PROTOCOL_HEADER + HOST_HEADER cho adapter dựng lại đúng https://<domain> | |
| // từ header nginx -> form action hết bị 403 mà KHÔNG cần hardcode ORIGIN. | |
| // | |
| // CỐ Ý không đặt ORIGIN cứng: để trống thì adapter-node tự dựng origin từ | |
| // PROTOCOL_HEADER + HOST_HEADER nên chạy đúng cả TRƯỚC và SAU khi có SSL. | |
| // Đặt ORIGIN=https://... ngay lúc tạo app sẽ sai vì cert chưa được cấp. | |
| // (Gợi ý bật ORIGIN thủ công được ghi dạng comment vào .env bên dưới.) | |
| PROTOCOL_HEADER: "x-forwarded-proto", | |
| HOST_HEADER: "host" | |
| }; | |
| if (opts.addressHeader) { | |
| envUpdates.ADDRESS_HEADER = "x-forwarded-for"; | |
| envUpdates.XFF_DEPTH = "1"; | |
| } | |
| if (dbInfo) { | |
| envUpdates.DB_CONNECTION = "mysql"; | |
| envUpdates.DB_HOST = "127.0.0.1"; | |
| envUpdates.DB_PORT = "3306"; | |
| envUpdates.DB_DATABASE = dbInfo.name; | |
| envUpdates.DB_USERNAME = dbInfo.user; | |
| envUpdates.DB_PASSWORD = dbInfo.password; | |
| } | |
| if (redisDbIndex !== void 0) { | |
| envUpdates.REDIS_HOST = "127.0.0.1"; | |
| envUpdates.REDIS_PORT = "6379"; | |
| envUpdates.REDIS_DB = String(redisDbIndex); | |
| envUpdates.REDIS_URL = `redis://127.0.0.1:6379/${redisDbIndex}`; | |
| } | |
| for (const kv of opts.env) { | |
| const eq = kv.indexOf("="); | |
| if (eq === -1) die(`--env ph\u1EA3i theo d\u1EA1ng KEY=VALUE, nh\u1EADn \u0111\u01B0\u1EE3c: '${kv}'`); | |
| const key = kv.slice(0, eq); | |
| validateEnvKey(key); | |
| envUpdates[key] = kv.slice(eq + 1); | |
| } | |
| const appWorkDir = unitWorkDir(webRoot, opts.appDir); | |
| if (appWorkDir !== webRoot) ensureDir(appWorkDir); | |
| const envPath = `${appWorkDir}/.env`; | |
| mergeEnvFile(envPath, envUpdates, 384); | |
| appendFile( | |
| envPath, | |
| [ | |
| "", | |
| "# --- SvelteKit \xB7 ki\u1EC3m tra CSRF khi ch\u1EA1y sau reverse proxy ---------------", | |
| '# adapter-node CH\u1EB6N m\u1ECDi POST/form action b\u1EB1ng 403 "Cross-site POST form', | |
| '# submissions are forbidden" n\u1EBFu Origin tr\xECnh duy\u1EC7t g\u1EEDi l\xEAn kh\xF4ng kh\u1EDBp', | |
| "# origin server t\u1EF1 suy ra. Sau proxy server ch\u1EC9 th\u1EA5y http://127.0.0.1 n\xEAn", | |
| "# r\u1EA5t d\u1EC5 l\u1EC7ch. PROTOCOL_HEADER + HOST_HEADER \u1EDF tr\xEAn \u0111\xE3 cho adapter d\u1EF1ng l\u1EA1i", | |
| `# \u0111\xFAng https://${domain2} t\u1EEB header nginx -> th\u01B0\u1EDDng KH\xD4NG c\u1EA7n \u0111\u1EB7t g\xEC th\xEAm.`, | |
| "#", | |
| "# N\u1EBFu v\u1EABn d\xEDnh 403 (ho\u1EB7c mu\u1ED1n ghim c\u1EE9ng origin), B\u1ECE COMMENT d\xF2ng d\u01B0\u1EDBi SAU", | |
| "# khi \u0111\xE3 c\u1EA5p SSL (napp cert issue) \u2014 tr\u01B0\u1EDBc \u0111\xF3 cert ch\u01B0a c\xF3, \u0111\u1EB7t https s\u1EBD sai:", | |
| `# ORIGIN=https://${domain2}`, | |
| "" | |
| ].join("\n") | |
| ); | |
| runCmd("chown", [`${user}:${user}`, envPath]); | |
| ok("\u0110\xE3 ghi c\u1EA5u h\xECnh v\xE0o .env (quy\u1EC1n 600, ch\u1EC9 user c\u1EE7a app \u0111\u1ECDc \u0111\u01B0\u1EE3c)"); | |
| runCmd("chown", ["-R", `${user}:${user}`, webRoot]); | |
| runCmd("find", [webRoot, "-type", "d", "-exec", "chmod", "750", "{}", "+"]); | |
| runCmd("find", [webRoot, "-type", "f", "-exec", "chmod", "640", "{}", "+"]); | |
| runCmd("chmod", ["600", envPath]); | |
| const explicitStatic = opts.staticRoot !== void 0 || (opts.staticPrefix?.length ?? 0) > 0 || (opts.staticAlias?.length ?? 0) > 0; | |
| let staticRoot = opts.staticRoot; | |
| let staticPrefixes = (opts.staticPrefix?.length ?? 0) > 0 ? opts.staticPrefix : void 0; | |
| let staticAliases = resolveStaticAliases(opts.staticAlias); | |
| let framework; | |
| const detected = detectStaticLayout(appWorkDir); | |
| if (detected) { | |
| framework = detected.framework; | |
| let applied = false; | |
| if (explicitStatic) { | |
| info(`Nh\u1EADn di\u1EC7n ${detected.framework}, nh\u01B0ng b\u1EA1n \u0111\xE3 truy\u1EC1n c\u1EA5u h\xECnh t\u0129nh ri\xEAng \u2014 gi\u1EEF nguy\xEAn b\u1EA3n c\u1EE7a b\u1EA1n.`); | |
| } else if (opts.autoStatic && !detected.risky) { | |
| staticRoot = detected.staticRoot; | |
| staticPrefixes = detected.staticPrefixes.length > 0 ? detected.staticPrefixes : void 0; | |
| staticAliases = detected.staticAliases; | |
| applied = true; | |
| } | |
| if (!explicitStatic) reportStaticDetection(domain2, detected, applied, opts.autoStatic ?? false); | |
| } else if (opts.autoStatic) { | |
| warn( | |
| `--auto-static: kh\xF4ng nh\u1EADn ra b\u1ED1 c\u1EE5c asset n\xE0o trong ${appWorkDir}. | |
| N\u1EBFu app ch\u01B0a build (thi\u1EBFu --build-cmd) th\xEC ch\u01B0a c\xF3 g\xEC tr\xEAn \u0111\u0129a \u0111\u1EC3 ph\u1EE5c v\u1EE5. B\u1ED1 c\u1EE5c l\u1EA1 th\xEC c\u1EA5u h\xECnh tay b\u1EB1ng 'napp app set ${domain2} --static-root ... --static-prefix ...'.` | |
| ); | |
| } | |
| let uploadDir = opts.uploadDir; | |
| let uploadPrefix = opts.uploadPrefix; | |
| if (uploadDir === void 0) { | |
| const upload = detectUploadDir(appWorkDir); | |
| if (upload) { | |
| if (opts.autoStatic) { | |
| uploadDir = upload.dir; | |
| uploadPrefix = upload.prefix; | |
| ok(`Nh\u1EADn di\u1EC7n th\u01B0 m\u1EE5c t\u1EA3i l\xEAn ${upload.dir} \u2192 nginx ph\u1EE5c v\u1EE5 t\u1EA1i ${upload.prefix}`); | |
| info(` N\xF3 n\u1EB1m trong '${upload.publicRoot}/' n\xEAn v\u1ED1n \u0111\xE3 c\xF4ng khai \u1EDF m\u1ECDi b\u1EA3n build \u2014 c\u1EA5u h\xECnh n\xE0y kh\xF4ng m\u1EDF th\xEAm g\xEC.`); | |
| } else { | |
| info(`Th\u1EA5y th\u01B0 m\u1EE5c t\u1EA3i l\xEAn ${upload.dir} nh\u01B0ng CH\u01AFA \u0111\u01B0\u1EE3c nginx ph\u1EE5c v\u1EE5.`); | |
| info( | |
| ` File t\u1EA3i l\xEAn SAU l\u1EA7n build g\u1EA7n nh\u1EA5t s\u1EBD tr\u1EA3 404 (build ch\u1EC9 sao ch\xE9p '${upload.publicRoot}/' v\xE0o output M\u1ED8T L\u1EA6N), r\u1ED3i t\u1EF1 hi\u1EC7n ra sau l\u1EA7n deploy k\u1EBF ti\u1EBFp \u2014 r\u1EA5t gi\u1ED1ng l\u1ED7i ch\u1EADp ch\u1EDDn.` | |
| ); | |
| info(` B\u1EADt b\u1EB1ng: sudo napp app set ${domain2} --upload-dir ${upload.dir}`); | |
| } | |
| } | |
| } | |
| ensureDir("/var/log/napp", 488); | |
| const record = { | |
| domain: domain2, | |
| aliasDomains: [], | |
| user, | |
| webRoot, | |
| port, | |
| nodeRuntime: opts.runtime, | |
| packageManager: pm, | |
| installCmd, | |
| buildCmd, | |
| startCmd, | |
| repoUrl: opts.repo, | |
| branch: opts.branch, | |
| dbName: dbInfo?.name, | |
| dbUser: dbInfo?.user, | |
| redisDbIndex, | |
| appDir: opts.appDir, | |
| maxBodySize: opts.maxBody, | |
| staticRoot, | |
| staticPrefixes, | |
| staticAliases: staticAliases.length > 0 ? staticAliases : void 0, | |
| framework, | |
| uploadDir, | |
| uploadPrefix, | |
| hotlinkProtect: opts.hotlinkProtect, | |
| hotlinkStrict: opts.hotlinkStrict, | |
| hotlinkAllow: opts.hotlinkAllow, | |
| createdAt: (/* @__PURE__ */ new Date()).toISOString(), | |
| updatedAt: (/* @__PURE__ */ new Date()).toISOString() | |
| }; | |
| const plan = currentHeapPlan({ webApps: 1 }); | |
| const heapMB = plan.webMB; | |
| writeAppUnit(record, heapMB); | |
| if (opts.runtime === "node") { | |
| info( | |
| `NODE_OPTIONS=--max-old-space-size=${heapMB} (web app \u0111\u01B0\u1EE3c ph\u1EA7n l\u1EDBn h\u01A1n background service: ${plan.webMB} MB so v\u1EDBi ${plan.serviceMB} MB; \u0111\u1ED5i trong .env n\u1EBFu c\u1EA7n)` | |
| ); | |
| } | |
| runCmd("systemctl", ["daemon-reload"]); | |
| runCmd("systemctl", ["enable", serviceName]); | |
| runCmd("systemctl", ["restart", serviceName]); | |
| ok(`\u0110\xE3 t\u1EA1o v\xE0 kh\u1EDFi \u0111\u1ED9ng systemd service '${serviceName}'`); | |
| ensureNappProxyConf(); | |
| const ngxConf = `${NGINX_AVAILABLE}/${domain2}.conf`; | |
| writeAppLocationsConf(record); | |
| writeFile(ngxConf, renderAppNginxConf(record, { ipv6: ipv6Available() }), 420); | |
| runCmd("ln", ["-sf", ngxConf, `${NGINX_ENABLED}/${domain2}.conf`]); | |
| const test = execCapture("nginx", ["-t"]); | |
| if (test.code !== 0) die(`Ki\u1EC3m tra c\u1EA5u h\xECnh nginx th\u1EA5t b\u1EA1i: | |
| ${test.stderr}`); | |
| runCmd("systemctl", ["reload", "nginx"]); | |
| ok("\u0110\xE3 k\xEDch ho\u1EA1t vhost nginx (ch\u1EC9 HTTP)"); | |
| upsertApp(record); | |
| rollbackActive = false; | |
| release(); | |
| ensureNginxCanServe(user, appServePaths(record)); | |
| const totalUnits = unitMix().webApps + unitMix().services; | |
| if (totalUnits > 1) reportBalance(applyNodeHeaps({ restart: true, skipRestartFor: domain2 })); | |
| console.log(); | |
| console.log("==============================================================="); | |
| ok("T\u1EA1o app th\xE0nh c\xF4ng!"); | |
| console.log(` T\xEAn mi\u1EC1n : http://${domain2}`); | |
| console.log(` M\xE3 ngu\u1ED3n : ${webRoot}`); | |
| console.log(` Ch\u1EA1y b\u1EB1ng : ${user} (systemd: ${serviceName})`); | |
| console.log(` Runtime : ${opts.runtime} \xB7 qu\u1EA3n l\xFD g\xF3i: ${pm}`); | |
| console.log(` C\u1ED5ng n\u1ED9i b\u1ED9 : 127.0.0.1:${port} (kh\xF4ng public \u2014 ch\u1EC9 nginx proxy v\xE0o)`); | |
| if (dbInfo) { | |
| console.log(` Database : ${dbInfo.name} (user: ${dbInfo.user}@localhost, m\u1EADt kh\u1EA9u trong .env)`); | |
| } | |
| if (redisDbIndex !== void 0) console.log(` Redis DB : #${redisDbIndex}`); | |
| console.log(); | |
| console.log(" C\xE1c b\u01B0\u1EDBc ti\u1EBFp theo:"); | |
| console.log(` 1. Tr\u1ECF b\u1EA3n ghi DNS A c\u1EE7a ${domain2} (v\xE0 www.${domain2} n\u1EBFu d\xF9ng) v\u1EC1 server n\xE0y.`); | |
| console.log(` 2. K\xEDch ho\u1EA1t SSL: sudo napp cert issue ${domain2}`); | |
| console.log(` 3. Xem log: sudo napp app logs ${domain2} -f`); | |
| console.log(" App SvelteKit d\xF9ng form action: n\u1EBFu POST b\u1ECB 403 CSRF, xem ghi ch\xFA ORIGIN trong .env."); | |
| console.log("==============================================================="); | |
| } catch (e) { | |
| rollback(); | |
| throw e; | |
| } | |
| } | |
| async function cmdAppDeploy(domain2) { | |
| requireRoot(); | |
| validateDomain(domain2); | |
| const app2 = requireApp(domain2); | |
| if (!app2.repoUrl) die(`App '${domain2}' kh\xF4ng c\xF3 --repo li\xEAn k\u1EBFt \u2014 kh\xF4ng c\xF3 g\xEC \u0111\u1EC3 deploy. H\xE3y t\u1EF1 c\u1EADp nh\u1EADt m\xE3 ngu\u1ED3n th\u1EE7 c\xF4ng r\u1ED3i 'napp app restart ${domain2}'.`); | |
| const release = acquireLock(domain2); | |
| try { | |
| section(`Deploy ${domain2}`); | |
| info(`\u0110ang git pull (${app2.branch})...`); | |
| runAs(app2.user, "git", ["fetch", "origin", app2.branch], { cwd: app2.webRoot, env: GIT_NONINTERACTIVE_ENV }); | |
| runAs(app2.user, "git", ["reset", "--hard", `origin/${app2.branch}`], { cwd: app2.webRoot, env: GIT_NONINTERACTIVE_ENV }); | |
| if (app2.packageManager) ensurePackageManager(app2.packageManager); | |
| info("\u0110ang c\xE0i dependencies..."); | |
| runAs(app2.user, "bash", ["-lc", app2.installCmd], { cwd: app2.webRoot }); | |
| if (app2.buildCmd) { | |
| info("\u0110ang build..."); | |
| runAs(app2.user, "bash", ["-lc", app2.buildCmd], { cwd: app2.webRoot }); | |
| } | |
| runCmd("chown", ["-R", `${app2.user}:${app2.user}`, app2.webRoot]); | |
| runCmd("chmod", ["600", `${unitWorkDir(app2.webRoot, app2.appDir)}/.env`], { silentFail: true }); | |
| runCmd("systemctl", ["restart", serviceNameFor(domain2)]); | |
| app2.updatedAt = (/* @__PURE__ */ new Date()).toISOString(); | |
| upsertApp(app2); | |
| ok(`Deploy ho\xE0n t\u1EA5t \u2014 \u0111\xE3 kh\u1EDFi \u0111\u1ED9ng l\u1EA1i ${serviceNameFor(domain2)}`); | |
| } finally { | |
| release(); | |
| } | |
| } | |
| function removeCert(domain2) { | |
| if (!commandExists("certbot")) { | |
| info("certbot kh\xF4ng c\xF3 s\u1EB5n \u2014 b\u1ECF qua xo\xE1 ch\u1EE9ng ch\u1EC9 SSL."); | |
| return; | |
| } | |
| const res = execCapture("certbot", ["delete", "--cert-name", domain2, "--non-interactive"]); | |
| if (res.code === 0) ok(`\u0110\xE3 xo\xE1 ch\u1EE9ng ch\u1EC9 SSL c\u1EE7a '${domain2}'.`); | |
| else info(`Kh\xF4ng c\xF3 ch\u1EE9ng ch\u1EC9 SSL t\xEAn '${domain2}' \u0111\u1EC3 xo\xE1 (ho\u1EB7c \u0111\xE3 xo\xE1 tr\u01B0\u1EDBc \u0111\xF3).`); | |
| } | |
| async function cmdAppRemove(domain2, opts) { | |
| requireRoot(); | |
| validateDomain(domain2); | |
| const app2 = requireApp(domain2); | |
| if (opts.ssl && !opts.nginx) { | |
| warn("B\u1EA1n ch\u1ECDn xo\xE1 SSL nh\u01B0ng gi\u1EEF c\u1EA5u h\xECnh nginx \u2014 vhost s\u1EBD tr\u1ECF t\u1EDBi ch\u1EE9ng ch\u1EC9 \u0111\xE3 xo\xE1 v\xE0 l\u1EA7n reload nginx sau c\xF3 th\u1EC3 tr\u01B0\u1EE3t. C\xE2n nh\u1EAFc xo\xE1 lu\xF4n c\u1EA5u h\xECnh nginx."); | |
| } | |
| if (!opts.nginx) { | |
| warn(`B\u1EA1n ch\u1ECDn gi\u1EEF c\u1EA5u h\xECnh nginx, nh\u01B0ng service systemd lu\xF4n b\u1ECB g\u1EE1 \u2014 '${domain2}' s\u1EBD tr\u1EA3 502 (kh\xF4ng c\xF2n ti\u1EBFn tr\xECnh l\u1EAFng nghe \u1EDF c\u1ED5ng ${app2.port}) cho t\u1EDBi khi b\u1EA1n d\u1EF1ng l\u1EA1i backend.`); | |
| } | |
| if (opts.source && !opts.database && app2.dbName) { | |
| warn( | |
| `B\u1EA1n ch\u1ECDn xo\xE1 m\xE3 ngu\u1ED3n nh\u01B0ng gi\u1EEF database '${app2.dbName}' \u2014 m\u1EADt kh\u1EA9u DB ch\u1EC9 l\u01B0u trong .env (n\u1EB1m trong m\xE3 ngu\u1ED3n), xo\xE1 \u0111i l\xE0 M\u1EA4T. Database v\xE0 d\u1EEF li\u1EC7u v\u1EABn c\xF2n, nh\u01B0ng mu\u1ED1n d\xF9ng l\u1EA1i ph\u1EA3i \u0111\u1EB7t m\u1EADt kh\u1EA9u m\u1EDBi: ALTER USER '${app2.dbUser ?? app2.dbName}'@'localhost' IDENTIFIED BY '<m\u1EADt kh\u1EA9u m\u1EDBi>'. H\xE3y sao ch\xE9p .env (ho\u1EB7c d\xF2ng DB_PASSWORD) ra n\u01A1i kh\xE1c tr\u01B0\u1EDBc n\u1EBFu c\u1EA7n.` | |
| ); | |
| } | |
| const willDelete = [ | |
| `service systemd (${serviceNameFor(domain2)})`, | |
| ...opts.nginx ? [`c\u1EA5u h\xECnh nginx (${domain2}.conf)`] : [], | |
| ...opts.ssl ? ["ch\u1EE9ng ch\u1EC9 SSL"] : [], | |
| ...opts.source ? [`m\xE3 ngu\u1ED3n (${app2.webRoot}) + user h\u1EC7 th\u1ED1ng '${app2.user}'`] : [], | |
| ...opts.database && app2.dbName ? [`database '${app2.dbName}'`] : [] | |
| ]; | |
| const willKeep = [ | |
| ...!opts.nginx ? ["c\u1EA5u h\xECnh nginx"] : [], | |
| ...!opts.source ? [`m\xE3 ngu\u1ED3n (${app2.webRoot})`] : [], | |
| ...!opts.database && app2.dbName ? [`database '${app2.dbName}'`] : [] | |
| ]; | |
| if (!opts.yes) { | |
| const readline2 = await import("node:readline/promises"); | |
| const rl2 = readline2.createInterface({ input: process.stdin, output: process.stdout }); | |
| const ans = await rl2.question( | |
| `Thao t\xE1c n\xE0y s\u1EBD g\u1EE1 app '${domain2}' kh\u1ECFi napp v\xE0 XO\xC1: | |
| ` + willDelete.map((w) => ` - ${w}`).join("\n") + (willKeep.length ? ` | |
| GI\u1EEE l\u1EA1i: | |
| ` + willKeep.map((w) => ` - ${w}`).join("\n") : "") + ` | |
| Ti\u1EBFp t\u1EE5c? [y/N] ` | |
| ); | |
| rl2.close(); | |
| if (!/^y(es)?$/i.test(ans.trim())) { | |
| info("\u0110\xE3 hu\u1EF7."); | |
| return; | |
| } | |
| } | |
| const release = acquireLock(domain2); | |
| try { | |
| const serviceName = serviceNameFor(domain2); | |
| runCmd("systemctl", ["stop", serviceName], { silentFail: true }); | |
| runCmd("systemctl", ["disable", serviceName], { silentFail: true }); | |
| runCmd("rm", ["-f", `${SYSTEMD_DIR}/${serviceName}.service`], { silentFail: true }); | |
| runCmd("systemctl", ["daemon-reload"], { silentFail: true }); | |
| if (opts.ssl) removeCert(domain2); | |
| if (opts.nginx) { | |
| runCmd("rm", ["-f", `${NGINX_ENABLED}/${domain2}.conf`, `${NGINX_AVAILABLE}/${domain2}.conf`, appLocationsPath(domain2)], { silentFail: true }); | |
| runCmd("bash", ["-lc", "nginx -t >/dev/null 2>&1 && systemctl reload nginx || true"], { silentFail: true }); | |
| ok(`\u0110\xE3 xo\xE1 c\u1EA5u h\xECnh nginx c\u1EE7a '${domain2}'.`); | |
| } else { | |
| info("Gi\u1EEF l\u1EA1i c\u1EA5u h\xECnh nginx."); | |
| } | |
| if (opts.source) { | |
| if ((0, import_node_fs13.existsSync)(app2.webRoot)) { | |
| try { | |
| (0, import_node_fs13.rmSync)(app2.webRoot, { recursive: true, force: true }); | |
| ok(`\u0110\xE3 xo\xE1 m\xE3 ngu\u1ED3n ${app2.webRoot}.`); | |
| } catch (e) { | |
| warn(`Kh\xF4ng xo\xE1 \u0111\u01B0\u1EE3c th\u01B0 m\u1EE5c m\xE3 ngu\u1ED3n ${app2.webRoot} (${e.message}) \u2014 h\xE3y t\u1EF1 xo\xE1 sau.`); | |
| } | |
| } | |
| const borrowers = servicesRunningAs(app2.domain, app2.user); | |
| if (borrowers.length > 0) { | |
| warn( | |
| `GI\u1EEE L\u1EA0I user h\u1EC7 th\u1ED1ng '${app2.user}' \u2014 ${borrowers.length} background service \u0111ang ch\u1EA1y b\u1EB1ng user n\xE0y (--run-as): | |
| ` + borrowers.map((s) => ` - ${s.name}`).join("\n") + ` | |
| Xo\xE1 user \u0111i l\xE0 c\xE1c service \u0111\xF3 ch\u1EBFt ngay l\u1EA7n kh\u1EDFi \u0111\u1ED9ng sau. G\u1EE1 ch\xFAng tr\u01B0\u1EDBc n\u1EBFu th\u1EADt s\u1EF1 mu\u1ED1n xo\xE1 user: | |
| ` + borrowers.map((s) => ` sudo napp service remove ${s.name} --source`).join("\n") + ` | |
| L\u01B0u \xFD: th\u01B0 m\u1EE5c ${app2.webRoot} v\u1EEBa xo\xE1 c\u0169ng n\u1EB1m trong ReadWritePaths c\u1EE7a ch\xFAng \u2014 systemd T\u1EEA CH\u1ED0I kh\u1EDFi \u0111\u1ED9ng unit khi \u0111\u01B0\u1EDDng d\u1EABn \u0111\xF3 kh\xF4ng c\xF2n.` | |
| ); | |
| } else if (execCapture("id", [app2.user]).code === 0) { | |
| runCmd("userdel", ["-r", app2.user], { silentFail: true }); | |
| ok(`\u0110\xE3 xo\xE1 user h\u1EC7 th\u1ED1ng '${app2.user}'.`); | |
| } | |
| } else { | |
| info(`Gi\u1EEF l\u1EA1i m\xE3 ngu\u1ED3n ${app2.webRoot} v\xE0 user h\u1EC7 th\u1ED1ng '${app2.user}'.`); | |
| } | |
| if (opts.database) { | |
| if (app2.dbName) { | |
| try { | |
| dropDatabase(app2.dbName, app2.dbUser); | |
| ok(`\u0110\xE3 xo\xE1 database '${app2.dbName}'.`); | |
| } catch (e) { | |
| warn( | |
| `Kh\xF4ng xo\xE1 \u0111\u01B0\u1EE3c database '${app2.dbName}' (${e.message}). C\xE1c t\xE0i nguy\xEAn kh\xE1c \u0111\xE3 x\u1EED l\xFD xong \u2014 h\xE3y t\u1EF1 xo\xE1 database n\xE0y sau b\u1EB1ng 'napp db drop ${app2.dbName} --yes --user ${app2.dbUser ?? app2.dbName}'.` | |
| ); | |
| } | |
| } else { | |
| info("App kh\xF4ng c\xF3 database ri\xEAng \u2014 b\u1ECF qua."); | |
| } | |
| } else if (app2.dbName) { | |
| info(`Gi\u1EEF l\u1EA1i database '${app2.dbName}'. Mu\u1ED1n xo\xE1 sau: napp db drop ${app2.dbName} --yes --user ${app2.dbUser ?? app2.dbName}`); | |
| } | |
| removeApp(domain2); | |
| ok(`\u0110\xE3 g\u1EE1 app '${domain2}' kh\u1ECFi napp.`); | |
| const mixAfter = unitMix(); | |
| if (mixAfter.webApps + mixAfter.services > 0) reportBalance(applyNodeHeaps({ restart: true })); | |
| } finally { | |
| release(); | |
| } | |
| } | |
| function listAppSummaries() { | |
| return Object.values(loadState().apps).map((a) => ({ | |
| domain: a.domain, | |
| port: a.port, | |
| running: execCapture("systemctl", ["is-active", "--quiet", serviceNameFor(a.domain)]).code === 0 | |
| })).sort((a, b) => a.domain.localeCompare(b.domain)); | |
| } | |
| function cmdAppList() { | |
| const s = loadState(); | |
| const apps = Object.values(s.apps); | |
| if (apps.length === 0) { | |
| info("Ch\u01B0a c\xF3 app n\xE0o \u0111\u01B0\u1EE3c napp qu\u1EA3n l\xFD. D\xF9ng 'napp app create <domain> ...' \u0111\u1EC3 t\u1EA1o m\u1EDBi."); | |
| return; | |
| } | |
| section(`Danh s\xE1ch app (${apps.length})`); | |
| for (const app2 of apps) { | |
| const running = execCapture("systemctl", ["is-active", "--quiet", serviceNameFor(app2.domain)]).code === 0; | |
| console.log( | |
| ` ${running ? "\u25CF" : "\u25CB"} ${app2.domain.padEnd(30)} port=${String(app2.port).padEnd(6)} ${`${app2.nodeRuntime}/${app2.packageManager ?? "npm"}`.padEnd(10)} user=${app2.user.padEnd(18)} ${app2.dbName ? `db=${app2.dbName} ` : ""}${app2.redisDbIndex !== void 0 ? `redis=${app2.redisDbIndex} ` : ""}${running ? "\u0111ang ch\u1EA1y" : "\u0110\xC3 D\u1EEANG"}` | |
| ); | |
| } | |
| } | |
| function cmdAppRestart(domain2) { | |
| requireRoot(); | |
| validateDomain(domain2); | |
| requireApp(domain2); | |
| runCmd("systemctl", ["restart", serviceNameFor(domain2)]); | |
| ok(`\u0110\xE3 kh\u1EDFi \u0111\u1ED9ng l\u1EA1i ${serviceNameFor(domain2)}`); | |
| } | |
| function cmdAppStop(domain2) { | |
| requireRoot(); | |
| validateDomain(domain2); | |
| requireApp(domain2); | |
| runCmd("systemctl", ["stop", serviceNameFor(domain2)]); | |
| ok(`\u0110\xE3 d\u1EEBng ${serviceNameFor(domain2)}`); | |
| } | |
| function cmdAppStart(domain2) { | |
| requireRoot(); | |
| validateDomain(domain2); | |
| requireApp(domain2); | |
| runCmd("systemctl", ["start", serviceNameFor(domain2)]); | |
| ok(`\u0110\xE3 kh\u1EDFi \u0111\u1ED9ng ${serviceNameFor(domain2)}`); | |
| } | |
| function cmdAppLogs(domain2, opts) { | |
| validateDomain(domain2); | |
| requireApp(domain2); | |
| const args = ["-u", serviceNameFor(domain2), "-n", String(opts.lines), "--no-pager"]; | |
| if (opts.follow) args.push("-f"); | |
| runCmd("journalctl", args); | |
| } | |
| function cmdAppEnvSet(domain2, pairs) { | |
| requireRoot(); | |
| validateDomain(domain2); | |
| const app2 = requireApp(domain2); | |
| const updates = {}; | |
| for (const kv of pairs) { | |
| const eq = kv.indexOf("="); | |
| if (eq === -1) die(`Tham s\u1ED1 ph\u1EA3i theo d\u1EA1ng KEY=VALUE, nh\u1EADn \u0111\u01B0\u1EE3c: '${kv}'`); | |
| const key = kv.slice(0, eq); | |
| validateEnvKey(key); | |
| updates[key] = kv.slice(eq + 1); | |
| } | |
| const appEnv = `${unitWorkDir(app2.webRoot, app2.appDir)}/.env`; | |
| mergeEnvFile(appEnv, updates, 384); | |
| runCmd("chown", [`${app2.user}:${app2.user}`, appEnv]); | |
| runCmd("chmod", ["600", appEnv]); | |
| ok(`\u0110\xE3 c\u1EADp nh\u1EADt .env cho '${domain2}'. Ch\u1EA1y 'napp app restart ${domain2}' \u0111\u1EC3 \xE1p d\u1EE5ng.`); | |
| } | |
| function cmdAppSet(domain2, opts) { | |
| requireRoot(); | |
| validateDomain(domain2); | |
| if (!commandExists("nginx")) die("nginx ch\u01B0a \u0111\u01B0\u1EE3c c\xE0i."); | |
| const app2 = requireApp(domain2); | |
| const changed = []; | |
| const set = (key, value, label) => { | |
| if (value === void 0) return; | |
| app2[key] = value; | |
| changed.push(label); | |
| }; | |
| if (opts.autoStatic) { | |
| const detected = detectStaticLayout(unitWorkDir(app2.webRoot, app2.appDir)); | |
| if (!detected) { | |
| die( | |
| `--auto-static: kh\xF4ng nh\u1EADn ra b\u1ED1 c\u1EE5c asset n\xE0o trong ${unitWorkDir(app2.webRoot, app2.appDir)}. | |
| App \u0111\xE3 build ch\u01B0a? B\u1ED1 c\u1EE5c l\u1EA1 th\xEC c\u1EA5u h\xECnh tay: --static-root <dir> --static-prefix <ti\u1EC1n-t\u1ED1>` | |
| ); | |
| } | |
| if (detected.risky) { | |
| die( | |
| `--auto-static: nh\u1EADn di\u1EC7n ${detected.framework}, nh\u01B0ng ti\u1EC1n t\u1ED1 '${suggestedPrefixes(detected).join(" ")}' kh\xF4ng ph\u1EA3i namespace ri\xEAng c\u1EE7a framework n\xEAn napp KH\xD4NG t\u1EF1 \xE1p. | |
| 'location ^~' th\u1EAFng c\u1EA3 route regex l\u1EABn proxy_pass \u2014 \xE1p nh\u1EA7m l\xE0 route th\u1EADt c\u1EE7a app ch\u1EBFt h\u1EB3n b\u1EB1ng 404. | |
| Ki\u1EC3m tra app kh\xF4ng d\xF9ng ti\u1EC1n t\u1ED1 \u0111\xF3 l\xE0m route, r\u1ED3i \xE1p tay: | |
| ${staticSetCommand(domain2, detected)}` | |
| ); | |
| } | |
| set("framework", detected.framework, `framework=${detected.framework}`); | |
| const force = (key, value, label) => { | |
| if (JSON.stringify(app2[key]) === JSON.stringify(value)) return; | |
| app2[key] = value; | |
| changed.push(label); | |
| }; | |
| force("staticRoot", detected.staticRoot, `static-root=${detected.staticRoot ?? "(b\u1ECF)"}`); | |
| force( | |
| "staticPrefixes", | |
| detected.staticPrefixes.length > 0 ? detected.staticPrefixes : void 0, | |
| `static-prefix=${detected.staticPrefixes.join(",") || "(b\u1ECF)"}` | |
| ); | |
| force( | |
| "staticAliases", | |
| detected.staticAliases.length > 0 ? detected.staticAliases : void 0, | |
| `static-alias=${detected.staticAliases.map((a) => a.prefix).join(",") || "(b\u1ECF)"}` | |
| ); | |
| if (detected.note) warn(`L\u01AFU \xDD (${detected.framework}): ${detected.note}`); | |
| if (!app2.uploadDir) { | |
| const upload = detectUploadDir(unitWorkDir(app2.webRoot, app2.appDir)); | |
| if (upload) { | |
| set("uploadDir", upload.dir, `upload-dir=${upload.dir}`); | |
| set("uploadPrefix", upload.prefix, `upload-prefix=${upload.prefix}`); | |
| info(`Nh\u1EADn di\u1EC7n th\u01B0 m\u1EE5c t\u1EA3i l\xEAn ${upload.dir} \u2192 ph\u1EE5c v\u1EE5 t\u1EA1i ${upload.prefix} (n\u1EB1m trong '${upload.publicRoot}/' n\xEAn v\u1ED1n \u0111\xE3 c\xF4ng khai).`); | |
| } | |
| } | |
| } | |
| set("staticRoot", opts.staticRoot, `static-root=${opts.staticRoot}`); | |
| if ((opts.staticPrefix?.length ?? 0) > 0) set("staticPrefixes", opts.staticPrefix, `static-prefix=${opts.staticPrefix.join(",")}`); | |
| if ((opts.staticAlias?.length ?? 0) > 0) { | |
| const aliases = resolveStaticAliases(opts.staticAlias); | |
| set("staticAliases", aliases, `static-alias=${aliases.map((a) => `${a.prefix}=${a.dir}`).join(",")}`); | |
| } | |
| set("uploadDir", opts.uploadDir, `upload-dir=${opts.uploadDir}`); | |
| set("uploadPrefix", opts.uploadPrefix, `upload-prefix=${opts.uploadPrefix}`); | |
| set("hotlinkProtect", opts.hotlinkProtect, `hotlink-protect=${opts.hotlinkProtect}`); | |
| set("hotlinkStrict", opts.hotlinkStrict, `hotlink-strict=${opts.hotlinkStrict}`); | |
| if ((opts.hotlinkAllow?.length ?? 0) > 0) set("hotlinkAllow", opts.hotlinkAllow, `hotlink-allow=${opts.hotlinkAllow.join(",")}`); | |
| set("maxBodySize", opts.maxBody, `max-body=${opts.maxBody}`); | |
| set("scanBlock", opts.scanBlock, `scan-block=${opts.scanBlock}`); | |
| if (changed.length === 0) { | |
| if (opts.autoStatic) { | |
| ok(`${domain2}: c\u1EA5u h\xECnh asset t\u0129nh \u0111\xE3 kh\u1EDBp v\u1EDBi framework nh\u1EADn di\u1EC7n \u0111\u01B0\u1EE3c \u2014 kh\xF4ng c\xF3 g\xEC \u0111\u1EC3 \u0111\u1ED5i.`); | |
| ensureNginxCanServe(app2.user, appServePaths(app2)); | |
| return; | |
| } | |
| die( | |
| `Kh\xF4ng c\xF3 g\xEC \u0111\u1EC3 \u0111\u1ED5i. Truy\u1EC1n \xEDt nh\u1EA5t m\u1ED9t tu\u1EF3 ch\u1ECDn, v\xED d\u1EE5: | |
| napp app set ${domain2} --auto-static (napp t\u1EF1 nh\u1EADn di\u1EC7n framework t\u1EEB th\u01B0 m\u1EE5c build) | |
| napp app set ${domain2} --static-root ${app2.webRoot}/build/client --static-prefix /_app/` | |
| ); | |
| } | |
| section(`C\u1EADp nh\u1EADt c\u1EA5u h\xECnh nginx cho ${domain2}`); | |
| info(`Thay \u0111\u1ED5i: ${changed.join(" \xB7 ")}`); | |
| const conf = `${NGINX_AVAILABLE}/${domain2}.conf`; | |
| if (!(0, import_node_fs13.existsSync)(conf)) die(`Kh\xF4ng th\u1EA5y vhost ${conf}. App n\xE0y c\xF3 \u0111\u01B0\u1EE3c napp t\u1EA1o kh\xF4ng?`); | |
| const confBak = `${conf}.napp-bak`; | |
| const locPath = appLocationsPath(domain2); | |
| const locBak = `${locPath}.napp-bak`; | |
| runCmd("cp", ["-a", conf, confBak]); | |
| const locExisted = (0, import_node_fs13.existsSync)(locPath); | |
| if (locExisted) runCmd("cp", ["-a", locPath, locBak]); | |
| writeAppLocationsConf(app2); | |
| let text = (0, import_node_fs13.readFileSync)(conf, "utf8"); | |
| if (opts.maxBody) { | |
| text = text.replace(/client_max_body_size\s+[^;]+;/, `client_max_body_size ${opts.maxBody};`); | |
| } | |
| const includeLine = `include ${locPath};`; | |
| const before = text; | |
| text = injectLocationsInclude(text, `proxy_pass http://napp_${slugFor(domain2)}`, includeLine); | |
| writeFile(conf, text, 420); | |
| const rollback = () => { | |
| runCmd("cp", ["-a", confBak, conf], { silentFail: true }); | |
| if (locExisted) runCmd("cp", ["-a", locBak, locPath], { silentFail: true }); | |
| else runCmd("rm", ["-f", locPath], { silentFail: true }); | |
| }; | |
| const test = execCapture("nginx", ["-t"]); | |
| if (test.code !== 0) { | |
| rollback(); | |
| runCmd("rm", ["-f", confBak, locBak], { silentFail: true }); | |
| die(`C\u1EA5u h\xECnh nginx sau khi s\u1EEDa c\xF3 l\u1ED7i \u2014 \u0110\xC3 HO\xC0N T\xC1C to\xE0n b\u1ED9: | |
| ${test.stderr}`); | |
| } | |
| runCmd("systemctl", ["reload", "nginx"]); | |
| runCmd("rm", ["-f", confBak, locBak], { silentFail: true }); | |
| app2.updatedAt = (/* @__PURE__ */ new Date()).toISOString(); | |
| upsertApp(app2); | |
| ensureNginxCanServe(app2.user, appServePaths(app2)); | |
| ok(`\u0110\xE3 c\u1EADp nh\u1EADt v\xE0 reload nginx.`); | |
| info(`\u2022 Location ri\xEAng: ${locPath}`); | |
| if (text !== before) info(`\u2022 \u0110\xE3 ch\xE8n '${includeLine}' v\xE0o vhost (m\u1ED9t l\u1EA7n duy nh\u1EA5t; l\u1EA7n sau ch\u1EC9 ghi l\u1EA1i file tr\xEAn).`); | |
| if (app2.staticRoot) info(`\u2022 Asset build gi\u1EDD do NGINX tr\u1EA3, kh\xF4ng qua Node.`); | |
| if ((app2.staticAliases?.length ?? 0) > 0) { | |
| info(`\u2022 Ti\u1EC1n t\u1ED1 ph\u1EE5c v\u1EE5 b\u1EB1ng alias: ${app2.staticAliases.map((a) => `${a.prefix} \u2192 ${a.dir}`).join(" \xB7 ")}`); | |
| } | |
| if (app2.uploadDir) info(`\u2022 File t\u1EA3i l\xEAn ph\u1EE5c v\u1EE5 t\u1EEB ${app2.uploadDir} (kh\xF4ng ph\u1EE5 thu\u1ED9c l\u1EA7n build g\u1EA7n nh\u1EA5t).`); | |
| if (opts.scanBlock === false) { | |
| warn( | |
| `\u2022 Ch\u1EB7n qu\xE9t l\u1ED7 h\u1ED5ng \u0110\xC3 T\u1EAET cho ${domain2}: request d\xF2 '/wp-login.php', '/phpmyadmin/'... l\u1EA1i \u0111i qua Node v\xE0 quay l\u1EA1i access log c\u1EE7a site. B\u1EADt l\u1EA1i: napp app set ${domain2} --scan-block` | |
| ); | |
| } else if (opts.scanBlock === true) { | |
| info(`\u2022 Ch\u1EB7n qu\xE9t l\u1ED7 h\u1ED5ng \u0111\xE3 B\u1EACT l\u1EA1i cho ${domain2} (danh s\xE1ch m\u1EABu d\xF9ng chung: napp nginx scanblock \u0111\u1EC3 b\u1EADt/t\u1EAFt to\xE0n m\xE1y).`); | |
| } | |
| if (app2.hotlinkProtect) reportHotlink(app2); | |
| } | |
| // src/lib/memwatch.ts | |
| var import_node_fs14 = require("node:fs"); | |
| var MEMWATCH_LOG = `${NAPP_ROOT}/memwatch.jsonl`; | |
| var HEAPSNAP_DIR = "/var/lib/napp/heapsnapshots"; | |
| var MEMWATCH_TIMER_NAME = "napp-memwatch"; | |
| var MAX_SAMPLES = 2e4; | |
| function allUnits() { | |
| const s = loadState(); | |
| return [ | |
| ...Object.keys(s.apps).map((d) => ({ unit: serviceNameFor(d), id: d, kind: "web" })), | |
| ...Object.keys(s.services).map((n) => ({ unit: svcSystemdName(n), id: n, kind: "service" })) | |
| ]; | |
| } | |
| function showProps(unit, props) { | |
| const res = execCapture("systemctl", ["show", ...props.flatMap((p) => ["-p", p]), `${unit}.service`]); | |
| const out = {}; | |
| if (res.code !== 0) return out; | |
| for (const line of res.stdout.split("\n")) { | |
| const eq = line.indexOf("="); | |
| if (eq > 0) out[line.slice(0, eq)] = line.slice(eq + 1).trim(); | |
| } | |
| return out; | |
| } | |
| function cgroupField(path, key) { | |
| if (!(0, import_node_fs14.existsSync)(path)) return void 0; | |
| try { | |
| for (const line of (0, import_node_fs14.readFileSync)(path, "utf8").split("\n")) { | |
| const [k, v] = line.split(/\s+/); | |
| if (k === key) return parseInt(v ?? "", 10); | |
| } | |
| } catch { | |
| } | |
| return void 0; | |
| } | |
| function cgroupNumber(path) { | |
| if (!(0, import_node_fs14.existsSync)(path)) return void 0; | |
| try { | |
| const raw = (0, import_node_fs14.readFileSync)(path, "utf8").trim(); | |
| if (raw === "max") return void 0; | |
| const n = parseInt(raw, 10); | |
| return Number.isFinite(n) ? n : void 0; | |
| } catch { | |
| return void 0; | |
| } | |
| } | |
| function procRssBytes(pid) { | |
| const p = `/proc/${pid}/status`; | |
| if (!(0, import_node_fs14.existsSync)(p)) return void 0; | |
| const m = /^VmRSS:\s+(\d+)\s+kB/m.exec((0, import_node_fs14.readFileSync)(p, "utf8")); | |
| return m?.[1] ? parseInt(m[1], 10) * 1024 : void 0; | |
| } | |
| function readUnitMemory(ref) { | |
| const props = showProps(ref.unit, ["ControlGroup", "MainPID", "NRestarts", "ActiveState", "MemoryHigh", "Result"]); | |
| const mainPid = parseInt(props.MainPID ?? "0", 10) || void 0; | |
| const cg = props.ControlGroup ? `/sys/fs/cgroup${props.ControlGroup}` : void 0; | |
| const anonBytes = cg ? cgroupField(`${cg}/memory.stat`, "anon") : void 0; | |
| const memoryHighBytes = props.MemoryHigh && props.MemoryHigh !== "infinity" ? parseInt(props.MemoryHigh, 10) : void 0; | |
| return { | |
| ...ref, | |
| active: props.ActiveState === "active", | |
| mainPid, | |
| // Ưu tiên 'anon' của cgroup; không có thì lùi về VmRSS (kém chính xác hơn | |
| // vì gồm cả trang file được chia sẻ, nhưng vẫn theo dõi được xu hướng). | |
| anonBytes: anonBytes ?? (mainPid ? procRssBytes(mainPid) : void 0), | |
| peakBytes: cg ? cgroupNumber(`${cg}/memory.peak`) : void 0, | |
| highEvents: cg ? cgroupField(`${cg}/memory.events`, "high") : void 0, | |
| memoryHighBytes, | |
| restarts: parseInt(props.NRestarts ?? "0", 10) || 0, | |
| lastResult: props.Result && props.Result !== "success" ? props.Result : void 0 | |
| }; | |
| } | |
| function readSamples() { | |
| if (!(0, import_node_fs14.existsSync)(MEMWATCH_LOG)) return []; | |
| const out = []; | |
| for (const line of (0, import_node_fs14.readFileSync)(MEMWATCH_LOG, "utf8").split("\n")) { | |
| if (!line.trim()) continue; | |
| try { | |
| const s = JSON.parse(line); | |
| if (typeof s.t === "number" && typeof s.a === "number") out.push(s); | |
| } catch { | |
| } | |
| } | |
| return out; | |
| } | |
| function takeSample() { | |
| const now = Math.floor(Date.now() / 1e3); | |
| const fresh = []; | |
| for (const ref of allUnits()) { | |
| const m = readUnitMemory(ref); | |
| if (!m.active || m.anonBytes === void 0) continue; | |
| fresh.push({ t: now, u: m.unit, a: m.anonBytes, r: m.restarts }); | |
| } | |
| if (fresh.length === 0) return 0; | |
| const kept = [...readSamples(), ...fresh].slice(-MAX_SAMPLES); | |
| ensureDir(NAPP_ROOT, 488); | |
| writeFile(MEMWATCH_LOG, kept.map((s) => JSON.stringify(s)).join("\n") + "\n", 416); | |
| return fresh.length; | |
| } | |
| function median(xs) { | |
| if (xs.length === 0) return 0; | |
| const s = [...xs].sort((a, b) => a - b); | |
| const mid = Math.floor(s.length / 2); | |
| return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2; | |
| } | |
| function analyseTrend(samples, unit) { | |
| const mine = samples.filter((s) => s.u === unit).sort((a, b) => a.t - b.t); | |
| const restartsSeen = mine.length > 1 ? Math.max(0, (mine[mine.length - 1]?.r ?? 0) - (mine[0]?.r ?? 0)) : 0; | |
| const lastR = mine[mine.length - 1]?.r; | |
| let start = mine.length; | |
| while (start > 0 && mine[start - 1]?.r === lastR) start--; | |
| const seg = mine.slice(start); | |
| const empty = { | |
| unit, | |
| samples: seg.length, | |
| spanHours: 0, | |
| baselineMB: 0, | |
| currentMB: 0, | |
| growthMB: 0, | |
| growthPct: 0, | |
| mbPerDay: 0, | |
| verdict: "insufficient", | |
| restartsSeen | |
| }; | |
| if (seg.length < 8) return empty; | |
| const spanHours = (seg[seg.length - 1].t - seg[0].t) / 3600; | |
| if (spanHours < 6) return { ...empty, spanHours }; | |
| const q = Math.max(2, Math.floor(seg.length / 4)); | |
| const baseline = median(seg.slice(0, q).map((s) => s.a)) / 1048576; | |
| const current = median(seg.slice(-q).map((s) => s.a)) / 1048576; | |
| const growthMB = current - baseline; | |
| const growthPct = baseline > 0 ? growthMB / baseline * 100 : 0; | |
| const midFirst = seg[Math.floor(q / 2)].t; | |
| const midLast = seg[seg.length - 1 - Math.floor(q / 2)].t; | |
| const rateHours = (midLast - midFirst) / 3600; | |
| const mbPerDay = rateHours > 0 ? growthMB / rateHours * 24 : 0; | |
| let verdict = "ok"; | |
| if (growthMB >= 64 && growthPct >= 50) verdict = "leak"; | |
| else if (growthMB >= 32 && growthPct >= 20) verdict = "watch"; | |
| return { | |
| unit, | |
| samples: seg.length, | |
| spanHours: Math.round(spanHours * 10) / 10, | |
| baselineMB: Math.round(baseline), | |
| currentMB: Math.round(current), | |
| growthMB: Math.round(growthMB), | |
| growthPct: Math.round(growthPct), | |
| mbPerDay: Math.round(mbPerDay), | |
| verdict, | |
| restartsSeen | |
| }; | |
| } | |
| function strayHeapSnapshots() { | |
| const s = loadState(); | |
| const dirs = [ | |
| ...Object.values(s.apps).map((a) => ({ id: a.domain, dir: unitWorkDir(a.webRoot, a.appDir) })), | |
| ...Object.values(s.services).map((v) => ({ id: v.name, dir: unitWorkDir(v.workDir, v.appDir) })) | |
| ]; | |
| const out = []; | |
| for (const d of dirs) { | |
| if (!(0, import_node_fs14.existsSync)(d.dir)) continue; | |
| try { | |
| const files = (0, import_node_fs14.readdirSync)(d.dir).filter((f) => f.endsWith(".heapsnapshot")).map((f) => ({ name: f, mb: Math.round((0, import_node_fs14.statSync)(`${d.dir}/${f}`).size / 1048576) })); | |
| if (files.length > 0) out.push({ ...d, files }); | |
| } catch { | |
| } | |
| } | |
| return out; | |
| } | |
| // src/commands/check.ts | |
| function osRelease() { | |
| const out = {}; | |
| if (!(0, import_node_fs15.existsSync)("/etc/os-release")) return out; | |
| const raw = (0, import_node_fs15.readFileSync)("/etc/os-release", "utf8"); | |
| for (const line of raw.split("\n")) { | |
| const m = line.match(/^([A-Z_]+)=(.*)$/); | |
| if (m) out[m[1]] = m[2].replace(/^"|"$/g, ""); | |
| } | |
| return out; | |
| } | |
| async function confirm(question, autoYes) { | |
| if (autoYes) return true; | |
| const readline2 = await import("node:readline/promises"); | |
| const rl2 = readline2.createInterface({ input: process.stdin, output: process.stdout }); | |
| const ans = await rl2.question(`${question} [y/N] `); | |
| rl2.close(); | |
| return /^y(es)?$/i.test(ans.trim()); | |
| } | |
| function installNodejs() { | |
| info("\u0110ang c\xE0i \u0111\u1EB7t Node.js 22.x LTS qua NodeSource..."); | |
| runCmd("bash", ["-lc", "curl -fsSL https://deb.nodesource.com/setup_22.x | bash -"]); | |
| runCmd("apt-get", ["install", "-y", "nodejs"]); | |
| runCmd("bash", ["-lc", "corepack enable || true"]); | |
| ok("\u0110\xE3 c\xE0i Node.js"); | |
| } | |
| function installNginx() { | |
| info("\u0110ang c\xE0i \u0111\u1EB7t nginx..."); | |
| runCmd("apt-get", ["update"]); | |
| runCmd("apt-get", ["install", "-y", "nginx"]); | |
| runCmd("systemctl", ["enable", "--now", "nginx"]); | |
| ok("\u0110\xE3 c\xE0i nginx"); | |
| } | |
| function installCertbot() { | |
| info("\u0110ang c\xE0i \u0111\u1EB7t certbot + plugin nginx..."); | |
| runCmd("apt-get", ["install", "-y", "certbot", "python3-certbot-nginx"]); | |
| ok("\u0110\xE3 c\xE0i certbot"); | |
| } | |
| function installMariadb() { | |
| info("\u0110ang c\xE0i \u0111\u1EB7t MariaDB server..."); | |
| runCmd("apt-get", ["install", "-y", "mariadb-server", "mariadb-client"]); | |
| runCmd("systemctl", ["enable", "--now", "mariadb"]); | |
| ok("\u0110\xE3 c\xE0i MariaDB (khuy\u1EBFn ngh\u1ECB ch\u1EA1y 'sudo mysql_secure_installation' \u0111\u1EC3 \u0111\u1EB7t m\u1EADt kh\u1EA9u root)"); | |
| } | |
| function installRedis() { | |
| info("\u0110ang c\xE0i \u0111\u1EB7t Redis server..."); | |
| runCmd("apt-get", ["install", "-y", "redis-server"]); | |
| runCmd("systemctl", ["enable", "--now", "redis-server"]); | |
| ok("\u0110\xE3 c\xE0i Redis"); | |
| } | |
| function redisEvictionPolicy() { | |
| if (!commandExists("redis-cli")) return null; | |
| const res = execCapture("redis-cli", ["CONFIG", "GET", "maxmemory-policy"]); | |
| if (res.code !== 0) return null; | |
| const lines = res.stdout.split("\n").map((l) => l.trim()).filter(Boolean); | |
| const value = lines[lines.length - 1] ?? ""; | |
| const known = /* @__PURE__ */ new Set(["noeviction", "volatile-lru", "allkeys-lru", "volatile-lfu", "allkeys-lfu", "volatile-random", "allkeys-random", "volatile-ttl"]); | |
| return known.has(value) ? value : null; | |
| } | |
| function fixRedisEvictionPolicy() { | |
| ensureDir("/etc/redis/conf.d", 493); | |
| const line = "maxmemory-policy noeviction"; | |
| if ((0, import_node_fs15.existsSync)(REDIS_TUNING_PATH)) { | |
| const content = (0, import_node_fs15.readFileSync)(REDIS_TUNING_PATH, "utf8"); | |
| const next = /^\s*maxmemory-policy\s+.*$/m.test(content) ? content.replace(/^\s*maxmemory-policy\s+.*$/m, line) : `${content.replace(/\n*$/, "\n")}${line} | |
| `; | |
| writeFile(REDIS_TUNING_PATH, next, 420); | |
| } else { | |
| writeFile( | |
| REDIS_TUNING_PATH, | |
| `# Managed by napp \u2014 \u0111\u1EB7t b\u1EDFi \`napp check --fix\` | |
| # BullMQ v\xE0 m\u1ECDi h\xE0ng \u0111\u1EE3i Redis Y\xCAU C\u1EA6U noeviction: d\u1EEF li\u1EC7u h\xE0ng \u0111\u1EE3i kh\xF4ng | |
| # ph\u1EA3i cache, \u0111\u1EC3 Redis t\u1EF1 tr\u1EE5c xu\u1EA5t l\xE0 m\u1EA5t job m\xE0 kh\xF4ng b\xEAn n\xE0o b\xE1o l\u1ED7i. | |
| # Ch\u1EA1y 'napp tune apply' \u0111\u1EC3 sinh \u0111\u1EA7y \u0111\u1EE7 c\u1EA5u h\xECnh Redis theo ph\u1EA7n c\u1EE9ng. | |
| ${line} | |
| `, | |
| 420 | |
| ); | |
| } | |
| const mainConf = "/etc/redis/redis.conf"; | |
| if ((0, import_node_fs15.existsSync)(mainConf)) { | |
| const content = (0, import_node_fs15.readFileSync)(mainConf, "utf8"); | |
| if (!content.includes("conf.d/*.conf")) { | |
| writeFile(mainConf, content + "\ninclude /etc/redis/conf.d/*.conf\n", 416); | |
| } | |
| } | |
| if (commandExists("redis-cli")) runCmd("redis-cli", ["CONFIG", "SET", "maxmemory-policy", "noeviction"], { silentFail: true }); | |
| ok("\u0110\xE3 \u0111\u1EB7t maxmemory-policy=noeviction (\xE1p ngay + ghi v\xE0o /etc/redis/conf.d/napp-tuning.conf)."); | |
| } | |
| function vhostsWithInlineProxyBuffers() { | |
| const stale = []; | |
| for (const domain2 of Object.keys(loadState().apps)) { | |
| const conf = `${NGINX_AVAILABLE}/${domain2}.conf`; | |
| if (!(0, import_node_fs15.existsSync)(conf)) continue; | |
| if (stripInlineProxyBuffers((0, import_node_fs15.readFileSync)(conf, "utf8")).changed) stale.push(domain2); | |
| } | |
| return stale; | |
| } | |
| function vhostsMissingLocationsInclude() { | |
| const out = []; | |
| for (const domain2 of Object.keys(loadState().apps)) { | |
| const conf = `${NGINX_AVAILABLE}/${domain2}.conf`; | |
| if (!(0, import_node_fs15.existsSync)(conf)) continue; | |
| if (!(0, import_node_fs15.readFileSync)(conf, "utf8").includes(`include ${appLocationsPath(domain2)};`)) out.push(domain2); | |
| } | |
| return out; | |
| } | |
| function scannerBlockNeverConfigured() { | |
| return Object.keys(loadState().apps).length > 0 && !(0, import_node_fs15.existsSync)(NGINX_SCANNER_BLOCK_CONF); | |
| } | |
| function unitsRestarting() { | |
| const out = []; | |
| for (const ref of allUnits()) { | |
| const m = readUnitMemory(ref); | |
| if (m.restarts > 0) out.push({ unit: m.unit, restarts: m.restarts, result: m.lastResult }); | |
| } | |
| return out; | |
| } | |
| function unitsMissingPriority() { | |
| const st = loadState(); | |
| const out = []; | |
| for (const domain2 of Object.keys(st.apps)) { | |
| if (!unitHasPriority(`${SYSTEMD_DIR}/${serviceNameFor(domain2)}.service`)) out.push(domain2); | |
| } | |
| for (const name of Object.keys(st.services)) { | |
| if (!unitHasPriority(`${SYSTEMD_DIR}/${svcSystemdName(name)}.service`)) out.push(name); | |
| } | |
| return out; | |
| } | |
| function staticCandidates() { | |
| const out = []; | |
| for (const app2 of Object.values(loadState().apps)) { | |
| if (app2.staticRoot || (app2.staticAliases?.length ?? 0) > 0) continue; | |
| const suggestion = detectStaticLayout(unitWorkDir(app2.webRoot, app2.appDir)); | |
| if (suggestion) out.push({ app: app2, suggestion }); | |
| } | |
| return out; | |
| } | |
| function unreadableStaticApps() { | |
| const nginxUser = nginxWorkerUser(); | |
| const out = []; | |
| for (const app2 of Object.values(loadState().apps)) { | |
| const paths = appServePaths(app2).filter((p) => (0, import_node_fs15.existsSync)(p)); | |
| if (paths.length === 0) continue; | |
| const bad = paths.filter((p) => pathReadableBy(nginxUser, p) === false); | |
| if (bad.length > 0) out.push({ app: app2, paths: bad }); | |
| } | |
| return out; | |
| } | |
| function uploadCandidates() { | |
| const out = []; | |
| for (const app2 of Object.values(loadState().apps)) { | |
| if (app2.uploadDir) continue; | |
| const upload = detectUploadDir(unitWorkDir(app2.webRoot, app2.appDir)); | |
| if (upload) out.push({ app: app2, dir: upload.dir, prefix: upload.prefix }); | |
| } | |
| return out; | |
| } | |
| function installFail2ban() { | |
| info("\u0110ang c\xE0i \u0111\u1EB7t fail2ban..."); | |
| runCmd("apt-get", ["install", "-y", "fail2ban"]); | |
| runCmd("systemctl", ["enable", "--now", "fail2ban"]); | |
| ok("\u0110\xE3 c\xE0i fail2ban (ch\u1EA1y 'napp fail2ban setup' \u0111\u1EC3 \xE1p c\u1EA5u h\xECnh jail)"); | |
| } | |
| function installUfw() { | |
| info("\u0110ang c\xE0i \u0111\u1EB7t UFW..."); | |
| runCmd("apt-get", ["install", "-y", "ufw"]); | |
| ok("\u0110\xE3 c\xE0i UFW (ch\u1EA1y 'napp firewall sync' \u0111\u1EC3 b\u1EADt v\xE0 c\u1EA5u h\xECnh)"); | |
| } | |
| function installGit() { | |
| runCmd("apt-get", ["install", "-y", "git"]); | |
| ok("\u0110\xE3 c\xE0i git"); | |
| } | |
| async function cmdCheck(opts) { | |
| if (opts.fix) requireRoot(); | |
| section("Ki\u1EC3m tra m\xF4i tr\u01B0\u1EDDng m\xE1y ch\u1EE7"); | |
| const findings = []; | |
| let hasApt2 = commandExists("apt-get"); | |
| if (!hasApt2) { | |
| warn("Kh\xF4ng t\xECm th\u1EA5y apt-get \u2014 c\xF4ng c\u1EE5 n\xE0y ch\u1EC9 h\u1ED7 tr\u1EE3 Ubuntu/Debian. Vi\u1EC7c t\u1EF1 c\xE0i \u0111\u1EB7t (--fix) s\u1EBD b\u1ECB b\u1ECF qua."); | |
| } | |
| const os2 = osRelease(); | |
| if (os2.ID === "ubuntu") { | |
| const major = parseInt((os2.VERSION_ID ?? "0").split(".")[0] ?? "0", 10); | |
| if (major >= 20 && major <= 26) { | |
| ok(`H\u1EC7 \u0111i\u1EC1u h\xE0nh: ${os2.PRETTY_NAME ?? "Ubuntu " + os2.VERSION_ID}`); | |
| } else { | |
| warn(`Ubuntu ${os2.VERSION_ID} n\u1EB1m ngo\xE0i ph\u1EA1m vi \u0111\xE3 ki\u1EC3m th\u1EED (20.04 - 26.04 LTS)`); | |
| } | |
| } else { | |
| warn(`H\u1EC7 \u0111i\u1EC1u h\xE0nh ch\u01B0a \u0111\u01B0\u1EE3c ki\u1EC3m th\u1EED: ${os2.PRETTY_NAME ?? "kh\xF4ng r\xF5"} (napp nh\u1EAFm t\u1EDBi Ubuntu 20.04 - 26.04 LTS)`); | |
| } | |
| if (commandExists("node")) { | |
| const v = execCapture("node", ["--version"]).stdout.trim(); | |
| ok(`Node.js ${v}`); | |
| } else { | |
| findings.push({ | |
| name: "Node.js", | |
| ok: false, | |
| message: "Node.js ch\u01B0a \u0111\u01B0\u1EE3c c\xE0i \u0111\u1EB7t (b\u1EAFt bu\u1ED9c \u2014 d\xF9ng \u0111\u1EC3 ch\u1EA1y c\xE1c app do napp qu\u1EA3n l\xFD).", | |
| fix: installNodejs | |
| }); | |
| } | |
| if (commandExists("sudo")) { | |
| ok("sudo \u0111\xE3 c\xE0i"); | |
| } else { | |
| findings.push({ | |
| name: "sudo", | |
| ok: false, | |
| message: "sudo ch\u01B0a \u0111\u01B0\u1EE3c c\xE0i (b\u1EAFt bu\u1ED9c \u2014 napp d\xF9ng 'sudo -u <user>' \u0111\u1EC3 ch\u1EA1y l\u1EC7nh c\xF4 l\u1EADp theo t\u1EEBng app).", | |
| fix: () => runCmd("apt-get", ["install", "-y", "sudo"]) | |
| }); | |
| } | |
| if (commandExists("git")) { | |
| ok(`git ${execCapture("git", ["--version"]).stdout.trim().replace(/^git version /, "")}`); | |
| } else { | |
| findings.push({ name: "git", ok: false, message: "git ch\u01B0a \u0111\u01B0\u1EE3c c\xE0i (c\u1EA7n cho deploy b\u1EB1ng --repo).", fix: installGit }); | |
| } | |
| if (commandExists("nginx")) { | |
| const running = isServiceActive("nginx"); | |
| if (running) ok("nginx \u0111\xE3 c\xE0i v\xE0 \u0111ang ch\u1EA1y"); | |
| else { | |
| findings.push({ | |
| name: "nginx", | |
| ok: false, | |
| message: "nginx \u0111\xE3 c\xE0i nh\u01B0ng ch\u01B0a ch\u1EA1y.", | |
| fix: () => runCmd("systemctl", ["enable", "--now", "nginx"]) | |
| }); | |
| } | |
| const stale = vhostsWithInlineProxyBuffers(); | |
| if (stale.length > 0) { | |
| findings.push({ | |
| name: "nginx-proxy-buffers", | |
| ok: false, | |
| message: `${stale.length} vhost c\xF2n kh\u1ED1i b\u1ED9 \u0111\u1EC7m proxy C\u0168 ngay trong 'location /' (${stale.join(", ")}). Gi\xE1 tr\u1ECB trong location th\u1EAFng gi\xE1 tr\u1ECB m\u1EE9c http, n\xEAn c\xE1c site n\xE0y v\u1EABn d\xF9ng proxy_buffer_size 16k v\xE0 v\u1EABn tr\u1EA3 502 ('upstream sent too big header') \u1EDF route SvelteKit l\u1ED3ng s\xE2u. S\u1EEDa: napp nginx sync`, | |
| fix: () => cmdNginxSync() | |
| }); | |
| } | |
| const noInclude = vhostsMissingLocationsInclude(); | |
| if (noInclude.length > 0) { | |
| findings.push({ | |
| name: "nginx-locations-include", | |
| ok: false, | |
| message: `${noInclude.length} vhost KH\xD4NG c\xF3 d\xF2ng 'include' file location c\u1EE7a napp (${noInclude.join(", ")}) \u2014 vhost t\u1EA1o b\u1EB1ng b\u1EA3n napp c\u0169. napp v\u1EABn ghi /etc/nginx/napp-locations/<domain>.conf \u0111\u1EA7y \u0111\u1EE7 nh\u01B0ng KH\xD4NG AI include n\xF3, n\xEAn asset t\u0129nh, th\u01B0 m\u1EE5c upload, ch\u1EB7n hotlink v\xE0 ch\u1EB7n qu\xE9t l\u1ED7 h\u1ED5ng \u0111\u1EC1u "\u0111\xE3 c\u1EA5u h\xECnh" m\xE0 kh\xF4ng h\u1EC1 ch\u1EA1y \u2014 nginx -t v\u1EABn xanh, kh\xF4ng c\xF3 l\u1ED7i n\xE0o \u0111\u1EC3 l\u1EA7n. S\u1EEDa: napp nginx sync`, | |
| fix: () => cmdNginxSync() | |
| }); | |
| } | |
| const noPriority = unitsMissingPriority(); | |
| if (noPriority.length > 0) { | |
| findings.push({ | |
| name: "systemd-priority", | |
| ok: false, | |
| message: `${noPriority.length} unit systemd ch\u01B0a c\xF3 CPUWeight/IOWeight (${noPriority.join(", ")}) \u2014 unit t\u1EA1o b\u1EB1ng b\u1EA3n napp c\u0169. Background worker \u0111ang tranh CPU NGANG C\u01A0 v\u1EDBi web app: m\u1ED9t worker n\xE9n \u1EA3nh/video l\xE0m m\u1ECDi request ch\u1EADm h\u1EB3n, m\xE0 kh\xF4ng c\xF3 g\xEC trong log \u0111\u1EC3 l\u1EA7n ra (\u0111\xF3 l\xE0 c\xE1ch kernel chia CPU khi kh\xF4ng ai n\xF3i g\xEC kh\xE1c). S\u1EEDa: napp tune apply \u2014 ho\u1EB7c ch\u1EA1y fix \u1EDF \u0111\xE2y (\xE1p b\u1EB1ng daemon-reload, KH\xD4NG c\u1EA7n restart app).`, | |
| // restart:false — CPUWeight/IOWeight/MemoryHigh áp được ngay khi | |
| // daemon-reload; chỉ heap mới cần khởi động lại tiến trình. | |
| fix: () => reportBalance(applyNodeHeaps({ restart: false })) | |
| }); | |
| } | |
| const restarting = unitsRestarting(); | |
| if (restarting.length > 0) { | |
| findings.push({ | |
| name: "memory-restarts", | |
| ok: false, | |
| message: `${restarting.length} \u0111\u01A1n v\u1ECB \u0111\xE3 b\u1ECB systemd KH\u1EDEI \u0110\u1ED8NG L\u1EA0I (${restarting.map((r) => `${r.unit}: ${r.restarts} l\u1EA7n${r.result ? `, g\u1EA7n nh\u1EA5t ${r.result}` : ""}`).join(" \xB7 ")}). Unit napp \u0111\u1EC1u c\xF3 'Restart=always' n\xEAn app ch\u1EA1m tr\u1EA7n heap s\u1EBD ch\u1EBFt r\u1ED3i T\u1EF0 S\u1ED0NG L\u1EA0I, l\u1EB7p nhi\u1EC1u ng\xE0y m\xE0 kh\xF4ng ai hay \u2014 \u0111\xE2y l\xE0 d\u1EA5u hi\u1EC7u r\xF2 r\u1EC9 b\u1ED9 nh\u1EDB r\xF5 nh\u1EA5t. Xem: napp mem status \xB7 nguy\xEAn nh\xE2n: journalctl -u <unit> | grep -i "out of memory"` | |
| // KHÔNG có fix tự động: đây là lỗi trong CODE của app, napp không sửa hộ được. | |
| }); | |
| } | |
| const leaking = allUnits().map((u) => analyseTrend(readSamples(), u.unit)).filter((t) => t.verdict === "leak"); | |
| if (leaking.length > 0) { | |
| findings.push({ | |
| name: "memory-trend", | |
| ok: false, | |
| message: `${leaking.length} \u0111\u01A1n v\u1ECB c\xF3 b\u1ED9 nh\u1EDB T\u0102NG LI\xCAN T\u1EE4C k\u1EC3 t\u1EEB l\u1EA7n kh\u1EDFi \u0111\u1ED9ng g\u1EA7n nh\u1EA5t (${leaking.map((t) => `${t.unit}: +${t.growthMB} MB/${t.spanHours}h, ~${t.mbPerDay} MB/ng\xE0y`).join(" \xB7 ")}). Ch\u1EE5p heap \u0111\u1EC3 t\xECm th\u1EE7 ph\u1EA1m: napp mem guard <app> r\u1ED3i napp mem snapshot <app>` | |
| }); | |
| } | |
| const stray = strayHeapSnapshots(); | |
| if (stray.length > 0) { | |
| findings.push({ | |
| name: "memory-heapsnapshot", | |
| ok: false, | |
| message: `C\xF3 file .heapsnapshot c\xF2n s\xF3t trong th\u01B0 m\u1EE5c app (${stray.map((s) => `${s.id}: ${s.files.length} file, ${s.files.reduce((n, f) => n + f.mb, 0)} MB`).join(" \xB7 ")}) \u2014 \u0111\xE2y l\xE0 B\u1EB0NG CH\u1EE8NG app \u0111\xE3 ch\u1EA1m tr\u1EA7n heap v\xE0 Node \u0111\xE3 t\u1EF1 ch\u1EE5p l\u1EA1i tr\u01B0\u1EDBc khi ch\u1EBFt. T\u1EA3i v\u1EC1 ph\xE2n t\xEDch b\u1EB1ng Chrome DevTools > Memory, r\u1ED3i XO\xC1 \u0111i (file r\u1EA5t to). Chi ti\u1EBFt: napp mem status` | |
| }); | |
| } | |
| if (scannerBlockNeverConfigured()) { | |
| findings.push({ | |
| name: "nginx-scanblock", | |
| ok: false, | |
| message: `Ch\u01B0a b\u1EADt ch\u1EB7n qu\xE9t l\u1ED7 h\u1ED5ng. Request d\xF2 CMS PHP ('/wp-login.php', '/phpmyadmin/', '/cgi-bin/'...) \u0111ang \u0111i tr\u1ECDn \u0111\u01B0\u1EDDng nginx -> Node -> render trang 404, v\xE0 tr\u1ED9n v\xE0o access log c\u1EE7a site. B\u1EADt: napp nginx scanblock (k\xE8m 'napp fail2ban setup' \u0111\u1EC3 ban IP ngay \u1EDF t\u01B0\u1EDDng l\u1EEDa \u2014 444 v\u1EABn ph\u1EA3i tr\u1EA3 ti\u1EC1n b\u1EAFt tay TLS, ban th\xEC kh\xF4ng).`, | |
| fix: () => cmdNginxScanBlock() | |
| }); | |
| } | |
| const candidates = staticCandidates(); | |
| const safe = candidates.filter((c) => !c.suggestion.risky); | |
| const risky = candidates.filter((c) => c.suggestion.risky); | |
| if (safe.length > 0) { | |
| findings.push({ | |
| name: "nginx-static", | |
| ok: false, | |
| message: `${safe.length} app \u0111ang \u0111\u1EA9y TO\xC0N B\u1ED8 asset t\u0129nh qua ti\u1EBFn tr\xECnh Node (${safe.map((c) => `${c.app.domain}: ${c.suggestion.framework}`).join(", ")}). M\u1ED7i trang k\xE9o h\xE0ng tr\u0103m chunk .js/.css x\u1EBFp h\xE0ng tr\xEAn event loop \u0111\u01A1n lu\u1ED3ng \u2014 app ch\u1EADm m\xE0 kh\xF4ng c\xF3 l\u1ED7i n\xE0o \u0111\u1EC3 l\u1EA7n. S\u1EEDa: ${safe.map((c) => `napp app set ${c.app.domain} --auto-static`).join(" \xB7 ")}`, | |
| fix: () => { | |
| for (const c of safe) cmdAppSet(c.app.domain, { autoStatic: true }); | |
| } | |
| }); | |
| } | |
| for (const c of risky) { | |
| warn( | |
| `${c.app.domain}: nh\u1EADn di\u1EC7n ${c.suggestion.framework} nh\u01B0ng ti\u1EC1n t\u1ED1 '${c.suggestion.staticPrefixes.join(" ")}' c\xF3 th\u1EC3 tr\xF9ng route th\u1EADt c\u1EE7a app \u2014 napp KH\xD4NG t\u1EF1 \xE1p. | |
| Ki\u1EC3m tra app kh\xF4ng d\xF9ng ti\u1EC1n t\u1ED1 \u0111\xF3 l\xE0m route, r\u1ED3i ch\u1EA1y: | |
| ${staticSetCommand(c.app.domain, c.suggestion)}` | |
| ); | |
| } | |
| const uploads = uploadCandidates(); | |
| if (uploads.length > 0) { | |
| findings.push({ | |
| name: "nginx-uploads", | |
| ok: false, | |
| message: `${uploads.length} app c\xF3 th\u01B0 m\u1EE5c file t\u1EA3i l\xEAn n\u1EB1m trong g\u1ED1c t\u0129nh c\xF4ng khai nh\u01B0ng nginx CH\u01AFA ph\u1EE5c v\u1EE5 (${uploads.map((u) => `${u.app.domain}: ${u.dir}`).join(", ")}). File t\u1EA3i l\xEAn SAU l\u1EA7n build g\u1EA7n nh\u1EA5t tr\u1EA3 404 d\xF9 c\xF3 th\u1EADt tr\xEAn \u0111\u0129a, r\u1ED3i t\u1EF1 hi\u1EC7n ra sau l\u1EA7n deploy k\u1EBF ti\u1EBFp \u2014 tr\xF4ng h\u1EC7t l\u1ED7i ch\u1EADp ch\u1EDDn. S\u1EEDa: ${uploads.map((u) => `napp app set ${u.app.domain} --upload-dir ${u.dir}`).join(" \xB7 ")}`, | |
| fix: () => { | |
| for (const u of uploads) cmdAppSet(u.app.domain, { uploadDir: u.dir, uploadPrefix: u.prefix }); | |
| } | |
| }); | |
| } | |
| const unreadable = unreadableStaticApps(); | |
| if (unreadable.length > 0) { | |
| const nginxUser = nginxWorkerUser(); | |
| findings.push({ | |
| name: "nginx-static-perm", | |
| ok: false, | |
| message: `${unreadable.length} app c\xF3 c\u1EA5u h\xECnh asset t\u0129nh nh\u01B0ng nginx (user '${nginxUser}') KH\xD4NG \u0111\u1ECDc \u0111\u01B0\u1EE3c th\u01B0 m\u1EE5c (${unreadable.map((u) => u.app.domain).join(", ")}). Th\u01B0 m\u1EE5c app thu\u1ED9c user ri\xEAng v\xE0 \u0111\u1EC3 750, nginx ch\u1EA1y b\u1EB1ng user kh\xE1c n\xEAn kh\xF4ng \u0111i xuy\xEAn qua \u0111\u01B0\u1EE3c \u2014 k\u1EBFt qu\u1EA3 l\xE0 403 ch\u1EE9 kh\xF4ng ph\u1EA3i file, v\xE0 log nginx ghi 'Permission denied' r\u1EA5t d\u1EC5 \u0111\u1ECDc nh\u1EA7m th\xE0nh sai \u0111\u01B0\u1EDDng d\u1EABn. S\u1EEDa: th\xEAm '${nginxUser}' v\xE0o nh\xF3m c\u1EE7a t\u1EEBng app r\u1ED3i restart nginx.`, | |
| fix: () => { | |
| for (const u of unreadable) { | |
| const res = grantNginxGroupAccess(u.app.user); | |
| if (res.changed) ok(`${u.app.domain}: ${res.message}`); | |
| else warn(`${u.app.domain}: ${res.message}`); | |
| } | |
| } | |
| }); | |
| } | |
| } else { | |
| findings.push({ name: "nginx", ok: false, message: "nginx ch\u01B0a \u0111\u01B0\u1EE3c c\xE0i \u0111\u1EB7t (b\u1EAFt bu\u1ED9c).", fix: installNginx }); | |
| } | |
| if (commandExists("certbot")) { | |
| const plugins = execCapture("certbot", ["plugins"]).stdout; | |
| if (/nginx/i.test(plugins)) ok("certbot \u0111\xE3 c\xE0i (c\xF3 plugin nginx)"); | |
| else { | |
| findings.push({ | |
| name: "certbot-nginx-plugin", | |
| ok: false, | |
| message: "certbot \u0111\xE3 c\xE0i nh\u01B0ng THI\u1EBEU plugin nginx.", | |
| fix: () => runCmd("apt-get", ["install", "-y", "python3-certbot-nginx"]) | |
| }); | |
| } | |
| } else { | |
| findings.push({ name: "certbot", ok: false, message: "certbot ch\u01B0a \u0111\u01B0\u1EE3c c\xE0i (c\u1EA7n cho SSL mi\u1EC5n ph\xED).", fix: installCertbot }); | |
| } | |
| if (isServiceActive("mariadb") || isServiceActive("mysql")) { | |
| ok("MariaDB/MySQL \u0111ang ch\u1EA1y"); | |
| } else if (commandExists("mysqld") || commandExists("mariadbd")) { | |
| findings.push({ | |
| name: "mariadb", | |
| ok: false, | |
| message: "MariaDB/MySQL \u0111\xE3 c\xE0i nh\u01B0ng ch\u01B0a ch\u1EA1y.", | |
| fix: () => runCmd("systemctl", ["enable", "--now", "mariadb"]) | |
| }); | |
| } else { | |
| findings.push({ name: "mariadb", ok: false, message: "MariaDB ch\u01B0a c\xE0i (b\u1EAFt bu\u1ED9c n\u1EBFu d\xF9ng napp db).", fix: installMariadb }); | |
| } | |
| if (isServiceActive("redis-server") || isServiceActive("redis")) { | |
| ok("Redis \u0111ang ch\u1EA1y"); | |
| const policy = redisEvictionPolicy(); | |
| if (policy === null) { | |
| warn("Kh\xF4ng \u0111\u1ECDc \u0111\u01B0\u1EE3c maxmemory-policy c\u1EE7a Redis (redis-cli thi\u1EBFu ho\u1EB7c c\u1EA7n m\u1EADt kh\u1EA9u) \u2014 h\xE3y t\u1EF1 ki\u1EC3m tra: redis-cli CONFIG GET maxmemory-policy (ph\u1EA3i l\xE0 'noeviction')."); | |
| } else if (policy === "noeviction") { | |
| ok("Redis maxmemory-policy = noeviction (\u0111\xFAng cho BullMQ/h\xE0ng \u0111\u1EE3i)"); | |
| } else { | |
| findings.push({ | |
| name: "redis-policy", | |
| ok: false, | |
| message: `Redis maxmemory-policy = '${policy}', BullMQ (v\xE0 m\u1ECDi h\xE0ng \u0111\u1EE3i Redis) y\xEAu c\u1EA7u 'noeviction'. Job \u0111ang ch\u1EDD kh\xF4ng ph\u1EA3i cache: khi ch\u1EA1m maxmemory, Redis s\u1EBD t\u1EF1 tr\u1EE5c xu\u1EA5t key v\xE0 job bi\u1EBFn m\u1EA5t gi\u1EEFa ch\u1EEBng m\xE0 KH\xD4NG b\xEAn n\xE0o b\xE1o l\u1ED7i.`, | |
| fix: fixRedisEvictionPolicy | |
| }); | |
| } | |
| } else if (commandExists("redis-server")) { | |
| findings.push({ | |
| name: "redis", | |
| ok: false, | |
| message: "Redis \u0111\xE3 c\xE0i nh\u01B0ng ch\u01B0a ch\u1EA1y.", | |
| fix: () => runCmd("systemctl", ["enable", "--now", "redis-server"]) | |
| }); | |
| } else { | |
| findings.push({ name: "redis", ok: false, message: "Redis ch\u01B0a c\xE0i (tu\u1EF3 ch\u1ECDn \u2014 b\u1ECF qua n\u1EBFu app kh\xF4ng d\xF9ng cache/queue Redis).", fix: installRedis }); | |
| } | |
| if (isServiceActive("fail2ban")) ok("fail2ban \u0111ang ch\u1EA1y"); | |
| else if (commandExists("fail2ban-client")) { | |
| findings.push({ | |
| name: "fail2ban", | |
| ok: false, | |
| message: "fail2ban \u0111\xE3 c\xE0i nh\u01B0ng ch\u01B0a ch\u1EA1y.", | |
| fix: () => runCmd("systemctl", ["enable", "--now", "fail2ban"]) | |
| }); | |
| } else { | |
| findings.push({ name: "fail2ban", ok: false, message: "fail2ban ch\u01B0a c\xE0i (khuy\u1EBFn ngh\u1ECB c\xE0i \u0111\u1EC3 ch\u1ED1ng brute-force).", fix: installFail2ban }); | |
| } | |
| if (commandExists("ufw")) { | |
| const status = execCapture("ufw", ["status"]).stdout; | |
| if (/Status: active/i.test(status)) ok("UFW \u0111\xE3 c\xE0i v\xE0 \u0111ang active"); | |
| else warn("UFW \u0111\xE3 c\xE0i nh\u01B0ng CH\u01AFA active \u2014 ch\u1EA1y 'napp firewall sync' \u0111\u1EC3 b\u1EADt (script s\u1EBD t\u1EF1 th\xEAm rule SSH tr\u01B0\u1EDBc khi b\u1EADt \u0111\u1EC3 tr\xE1nh kho\xE1 b\u1EA1n ra ngo\xE0i)."); | |
| } else { | |
| findings.push({ name: "ufw", ok: false, message: "UFW ch\u01B0a c\xE0i (khuy\u1EBFn ngh\u1ECB \u0111\u1EC3 gi\u1EDBi h\u1EA1n c\u1ED5ng m\u1EDF).", fix: installUfw }); | |
| } | |
| if (findings.length === 0) { | |
| ok("M\xF4i tr\u01B0\u1EDDng \u0111\xE3 s\u1EB5n s\xE0ng \u0111\u1EA7y \u0111\u1EE7."); | |
| info("Ki\u1EC3m tra th\xEAm v\u1EC1 B\u1EA2O M\u1EACT (b\u1EA3n v\xE1 \u0111ang ch\u1EDD, r\u1EE7i ro dependencies): sudo napp doctor"); | |
| return; | |
| } | |
| console.log(); | |
| warn(`Ph\xE1t hi\u1EC7n ${findings.length} m\u1EE5c c\u1EA7n ch\xFA \xFD:`); | |
| for (const f of findings) console.log(` - [${f.name}] ${f.message}`); | |
| if (!opts.fix) { | |
| console.log(); | |
| info("Ch\u1EA1y l\u1EA1i v\u1EDBi '--fix' (c\u1EA7n sudo) \u0111\u1EC3 napp t\u1EF1 c\xE0i/kh\u1EDFi \u0111\u1ED9ng c\xE1c th\xE0nh ph\u1EA7n c\xF2n thi\u1EBFu."); | |
| return; | |
| } | |
| if (!hasApt2) { | |
| warn("B\u1ECF qua --fix v\xEC kh\xF4ng c\xF3 apt-get tr\xEAn h\u1EC7 th\u1ED1ng n\xE0y."); | |
| return; | |
| } | |
| console.log(); | |
| const proceed = await confirm(`Ti\u1EBFn h\xE0nh c\xE0i \u0111\u1EB7t/kh\u1EDFi \u0111\u1ED9ng ${findings.length} th\xE0nh ph\u1EA7n c\xF2n thi\u1EBFu \u1EDF tr\xEAn?`, opts.yes); | |
| if (!proceed) { | |
| info("\u0110\xE3 hu\u1EF7. Kh\xF4ng thay \u0111\u1ED5i g\xEC."); | |
| return; | |
| } | |
| for (const f of findings) { | |
| if (f.fix) f.fix(); | |
| } | |
| ok("Ho\xE0n t\u1EA5t --fix. Ch\u1EA1y l\u1EA1i 'napp check' \u0111\u1EC3 x\xE1c nh\u1EADn."); | |
| } | |
| // src/commands/doctor.ts | |
| var import_node_fs18 = require("node:fs"); | |
| var import_node_zlib = require("node:zlib"); | |
| // src/lib/apt.ts | |
| var import_node_fs16 = require("node:fs"); | |
| var CRITICAL_PKG_RE = /^(nginx|nginx-\w+|openssl|libssl[0-9.]*|libcrypto\S*|openssh-\S+|libc6|libc-bin|zlib1g|libcurl\S*|curl|nodejs|npm|mariadb-\S+|mysql-\S+|libmariadb\S*|redis\S*|certbot|python3-certbot\S*|sudo|systemd|libsystemd\S*|libexpat\S*|libxml2|libpcre\S*|git)$/; | |
| function hasApt() { | |
| return commandExists("apt-get"); | |
| } | |
| function isCriticalPackage(pkg) { | |
| return CRITICAL_PKG_RE.test(pkg); | |
| } | |
| function aptIndexAgeSeconds() { | |
| for (const p of ["/var/lib/apt/periodic/update-success-stamp", "/var/cache/apt/pkgcache.bin", "/var/lib/apt/lists"]) { | |
| try { | |
| return Math.floor((Date.now() - (0, import_node_fs16.statSync)(p).mtimeMs) / 1e3); | |
| } catch { | |
| } | |
| } | |
| return void 0; | |
| } | |
| function pendingUpdates() { | |
| if (!hasApt()) return []; | |
| const res = execCapture("apt-get", ["-s", "-q", "-o", "Debug::NoLocking=1", "dist-upgrade"]); | |
| if (res.code !== 0 && !res.stdout) return []; | |
| const out = []; | |
| for (const line of res.stdout.split("\n")) { | |
| const m = line.match(/^Inst\s+(\S+)\s+(?:\[([^\]]*)\]\s+)?\(([^\s)]+)\s+([^)]*)\)/); | |
| if (!m) continue; | |
| const origins = m[4] ?? ""; | |
| out.push({ | |
| pkg: m[1], | |
| currentVersion: m[2] ?? "", | |
| newVersion: m[3], | |
| origins, | |
| // Ubuntu: ".../noble-security"; Debian: "Debian:12/oldstable-security" hoặc "Debian-Security:12". | |
| security: /security/i.test(origins) | |
| }); | |
| } | |
| return out; | |
| } | |
| function rebootRequired() { | |
| const flag = "/var/run/reboot-required"; | |
| if (!(0, import_node_fs16.existsSync)(flag)) return { required: false, packages: [] }; | |
| let packages = []; | |
| try { | |
| packages = (0, import_node_fs16.readFileSync)(`${flag}.pkgs`, "utf8").split("\n").map((s) => s.trim()).filter(Boolean); | |
| } catch { | |
| } | |
| return { required: true, packages: [...new Set(packages)] }; | |
| } | |
| function unitStaleLibraries(unit) { | |
| const pidOut = execCapture("systemctl", ["show", "-p", "MainPID", "--value", unit]).stdout.trim(); | |
| const pid = parseInt(pidOut, 10); | |
| if (!Number.isInteger(pid) || pid <= 0) return void 0; | |
| let maps; | |
| try { | |
| maps = (0, import_node_fs16.readFileSync)(`/proc/${pid}/maps`, "utf8"); | |
| } catch { | |
| return void 0; | |
| } | |
| const stale = /* @__PURE__ */ new Set(); | |
| for (const line of maps.split("\n")) { | |
| const m = line.match(/\s(\/\S.*?)\s+\(deleted\)$/); | |
| if (!m) continue; | |
| const path = m[1]; | |
| if (/^\/(memfd:|dev\/|SYSV|drm|anon_|\[)/.test(path)) continue; | |
| if (!/\.so($|\.)|^\/usr\/(s?bin|lib|libexec)\//.test(path)) continue; | |
| stale.add(path); | |
| } | |
| return [...stale]; | |
| } | |
| // src/lib/cve.ts | |
| var DIRECTIVE_START = "(?:^|[;{}\\s])"; | |
| var directive = (body) => new RegExp(DIRECTIVE_START + body, "m"); | |
| var HTTP2_ON = directive("(?:listen\\s[^;]*\\bhttp2\\b|http2\\s+on\\s*;)"); | |
| var NGINX_CVES = [ | |
| { | |
| id: "CVE-2025-23419", | |
| cveIds: ["CVE-2025-23419"], | |
| severity: "medium", | |
| fixedIn: ["1.26.3", "1.27.4"], | |
| summary: "TLS session resumption d\xF9ng chung gi\u1EEFa c\xE1c server block \u2014 c\xF3 th\u1EC3 v\u01B0\u1EE3t qua x\xE1c th\u1EF1c client certificate (mTLS).", | |
| condition: "Ch\u1EC9 \u1EA3nh h\u01B0\u1EDFng khi d\xF9ng client certificate (ssl_verify_client) tr\xEAn nhi\u1EC1u server block.", | |
| requiresDirective: directive("ssl_verify_client\\s+(?!off\\b)"), | |
| notApplicableWhy: "c\u1EA5u h\xECnh kh\xF4ng b\u1EADt x\xE1c th\u1EF1c client certificate (ssl_verify_client)" | |
| }, | |
| { | |
| id: "CVE-2024-7347", | |
| cveIds: ["CVE-2024-7347"], | |
| severity: "high", | |
| fixedIn: ["1.26.2", "1.27.1"], | |
| summary: "\u0110\u1ECDc b\u1ED9 nh\u1EDB ngo\xE0i v\xF9ng trong ngx_http_mp4_module \u2014 file mp4 d\u1EF1ng ri\xEAng l\xE0m nginx worker crash.", | |
| condition: "Ch\u1EC9 \u1EA3nh h\u01B0\u1EDFng khi b\u1EADt module mp4 (directive 'mp4' trong c\u1EA5u h\xECnh).", | |
| requiresBuildFlag: /--with-http_mp4_module/, | |
| requiresDirective: directive("mp4\\s*;"), | |
| notApplicableWhy: "c\u1EA5u h\xECnh kh\xF4ng d\xF9ng directive 'mp4'" | |
| }, | |
| { | |
| id: "CVE-2023-44487", | |
| cveIds: ["CVE-2023-44487"], | |
| severity: "high", | |
| fixedIn: ["1.25.3"], | |
| summary: "HTTP/2 Rapid Reset \u2014 client m\u1EDF/hu\u1EF7 stream li\xEAn t\u1EE5c l\xE0m c\u1EA1n t\xE0i nguy\xEAn m\xE1y ch\u1EE7 (DoS).", | |
| condition: "\u1EA2nh h\u01B0\u1EDFng khi b\u1EADt HTTP/2. Gi\u1EA3m thi\u1EC3u: keepalive_requests + limit_req (napp tune \u0111\xE3 \u0111\u1EB7t gi\u1EDBi h\u1EA1n c\u01A1 b\u1EA3n).", | |
| requiresDirective: HTTP2_ON, | |
| notApplicableWhy: "c\u1EA5u h\xECnh kh\xF4ng b\u1EADt HTTP/2" | |
| }, | |
| { | |
| id: "CVE-2022-41741/41742", | |
| cveIds: ["CVE-2022-41741", "CVE-2022-41742"], | |
| severity: "high", | |
| fixedIn: ["1.22.1", "1.23.2"], | |
| summary: "Ghi/\u0111\u1ECDc ngo\xE0i v\xF9ng nh\u1EDB trong ngx_http_mp4_module \u2014 c\xF3 th\u1EC3 crash worker ho\u1EB7c l\u1ED9 b\u1ED9 nh\u1EDB.", | |
| condition: "Ch\u1EC9 \u1EA3nh h\u01B0\u1EDFng khi b\u1EADt module mp4.", | |
| requiresBuildFlag: /--with-http_mp4_module/, | |
| requiresDirective: directive("mp4\\s*;"), | |
| notApplicableWhy: "c\u1EA5u h\xECnh kh\xF4ng d\xF9ng directive 'mp4'" | |
| }, | |
| { | |
| id: "CVE-2021-23017", | |
| cveIds: ["CVE-2021-23017"], | |
| severity: "critical", | |
| fixedIn: ["1.20.1", "1.21.0"], | |
| summary: "L\u1ED7i off-by-one trong resolver \u2014 k\u1EBB t\u1EA5n c\xF4ng gi\u1EA3 m\u1EA1o ph\u1EA3n h\u1ED3i DNS c\xF3 th\u1EC3 ghi \u0111\xE8 b\u1ED9 nh\u1EDB, d\u1EABn t\u1EDBi TH\u1EF0C THI M\xC3 T\u1EEA XA.", | |
| condition: "Ch\u1EC9 \u1EA3nh h\u01B0\u1EDFng khi c\u1EA5u h\xECnh c\xF3 directive 'resolver'.", | |
| requiresDirective: directive("resolver\\s+\\S"), | |
| notApplicableWhy: "c\u1EA5u h\xECnh kh\xF4ng c\xF3 directive 'resolver'" | |
| }, | |
| { | |
| id: "CVE-2019-20372", | |
| cveIds: ["CVE-2019-20372"], | |
| severity: "high", | |
| fixedIn: ["1.17.7"], | |
| summary: "Request smuggling qua error_page \u2014 ch\xE8n \u0111\u01B0\u1EE3c request th\u1EE9 hai v\xE0o k\u1EBFt n\u1ED1i t\u1EDBi backend.", | |
| condition: "\u1EA2nh h\u01B0\u1EDFng khi d\xF9ng error_page k\xE8m chuy\u1EC3n h\u01B0\u1EDBng n\u1ED9i b\u1ED9.", | |
| requiresDirective: directive("error_page\\s+\\S"), | |
| notApplicableWhy: "c\u1EA5u h\xECnh kh\xF4ng d\xF9ng error_page" | |
| }, | |
| { | |
| id: "CVE-2019-9511/9513", | |
| cveIds: ["CVE-2019-9511", "CVE-2019-9513"], | |
| severity: "high", | |
| fixedIn: ["1.16.1", "1.17.3"], | |
| summary: "Nh\xF3m l\u1ED7 h\u1ED5ng DoS c\u1EE7a HTTP/2 (Data Dribble, Ping Flood, Resource Loop).", | |
| condition: "\u1EA2nh h\u01B0\u1EDFng khi b\u1EADt HTTP/2.", | |
| requiresDirective: HTTP2_ON, | |
| notApplicableWhy: "c\u1EA5u h\xECnh kh\xF4ng b\u1EADt HTTP/2" | |
| } | |
| ]; | |
| function compareVersions(a, b) { | |
| const pa = a.split(".").map((x) => parseInt(x, 10) || 0); | |
| const pb = b.split(".").map((x) => parseInt(x, 10) || 0); | |
| for (let i = 0; i < Math.max(pa.length, pb.length); i++) { | |
| const d = (pa[i] ?? 0) - (pb[i] ?? 0); | |
| if (d !== 0) return d > 0 ? 1 : -1; | |
| } | |
| return 0; | |
| } | |
| function isAffected(version, cve) { | |
| const branchOf = (v) => v.split(".").slice(0, 2).join("."); | |
| const vBranch = branchOf(version); | |
| let best; | |
| for (const fixed of cve.fixedIn) { | |
| if (compareVersions(branchOf(fixed), vBranch) <= 0) { | |
| if (!best || compareVersions(branchOf(fixed), branchOf(best)) > 0) best = fixed; | |
| } | |
| } | |
| if (!best) return true; | |
| return compareVersions(version, best) < 0; | |
| } | |
| function nginxCvesFor(version) { | |
| return NGINX_CVES.filter((c) => isAffected(version, c)); | |
| } | |
| function assessNginxCves(version, ev) { | |
| return nginxCvesFor(version).map((cve) => { | |
| if (ev.changelog) { | |
| const hit = cve.cveIds.find((id) => ev.changelog.includes(id)); | |
| if (hit) { | |
| return { cve, verdict: "patched", reason: `changelog c\u1EE7a g\xF3i \u0111\xE3 c\xE0i c\xF3 ghi ${hit} (b\u1EA3n v\xE1 backport)` }; | |
| } | |
| } | |
| if (cve.requiresBuildFlag && ev.buildFlags && !cve.requiresBuildFlag.test(ev.buildFlags)) { | |
| return { cve, verdict: "not-applicable", reason: "nginx kh\xF4ng \u0111\u01B0\u1EE3c bi\xEAn d\u1ECBch k\xE8m module ch\u1EE9a l\u1ED7 h\u1ED5ng" }; | |
| } | |
| if (cve.requiresDirective && ev.config && !cve.requiresDirective.test(ev.config)) { | |
| return { cve, verdict: "not-applicable", reason: cve.notApplicableWhy ?? "c\u1EA5u h\xECnh kh\xF4ng k\xEDch ho\u1EA1t ph\u1EA7n ch\u1EE9a l\u1ED7 h\u1ED5ng" }; | |
| } | |
| const missing = []; | |
| if (!ev.changelog) missing.push("changelog g\xF3i"); | |
| if (cve.requiresDirective && !ev.config) missing.push("c\u1EA5u h\xECnh \u0111ang ch\u1EA1y"); | |
| return { | |
| cve, | |
| verdict: "affected", | |
| reason: missing.length ? `ch\u01B0a \u0111\u1ED1i chi\u1EBFu \u0111\u01B0\u1EE3c ${missing.join(" + ")}` : "kh\xF4ng t\xECm th\u1EA5y b\u1EB1ng ch\u1EE9ng \u0111\xE3 v\xE1, v\xE0 \u0111i\u1EC1u ki\u1EC7n k\xEDch ho\u1EA1t C\xD3 tr\xEAn m\xE1y n\xE0y" | |
| }; | |
| }); | |
| } | |
| var NODE_EOL = { | |
| 14: "2023-04-30", | |
| 16: "2023-09-11", | |
| 18: "2025-04-30", | |
| 20: "2026-04-30", | |
| 22: "2027-04-30", | |
| 24: "2028-04-30" | |
| }; | |
| function nodeLifecycle(version, now = /* @__PURE__ */ new Date()) { | |
| const major = parseInt(version.replace(/^v/, "").split(".")[0] ?? "0", 10); | |
| const lts = major % 2 === 0; | |
| const eolDate = NODE_EOL[major]; | |
| const eol = eolDate ? new Date(eolDate).getTime() < now.getTime() : !lts; | |
| return { major, lts, eolDate, eol }; | |
| } | |
| // src/lib/deps.ts | |
| var import_node_fs17 = require("node:fs"); | |
| var SEVERITY_ORDER = { | |
| critical: 0, | |
| high: 1, | |
| medium: 2, | |
| low: 3, | |
| info: 4 | |
| }; | |
| var LOCKFILES = { | |
| npm: ["package-lock.json", "npm-shrinkwrap.json"], | |
| pnpm: ["pnpm-lock.yaml"], | |
| yarn: ["yarn.lock"], | |
| bun: ["bun.lock", "bun.lockb"] | |
| }; | |
| var ALL_LOCKFILES = ["package-lock.json", "npm-shrinkwrap.json", "pnpm-lock.yaml", "yarn.lock", "bun.lock", "bun.lockb"]; | |
| var POPULAR_PACKAGES = [ | |
| "express", | |
| "react", | |
| "react-dom", | |
| "lodash", | |
| "axios", | |
| "moment", | |
| "chalk", | |
| "commander", | |
| "dotenv", | |
| "debug", | |
| "request", | |
| "async", | |
| "bluebird", | |
| "mongoose", | |
| "mysql2", | |
| "pg", | |
| "redis", | |
| "ioredis", | |
| "socket.io", | |
| "ws", | |
| "jsonwebtoken", | |
| "bcrypt", | |
| "bcryptjs", | |
| "passport", | |
| "cors", | |
| "helmet", | |
| "body-parser", | |
| "cookie-parser", | |
| "multer", | |
| "nodemailer", | |
| "winston", | |
| "pino", | |
| "morgan", | |
| "joi", | |
| "yup", | |
| "zod", | |
| "uuid", | |
| "nanoid", | |
| "typescript", | |
| "eslint", | |
| "prettier", | |
| "jest", | |
| "mocha", | |
| "chai", | |
| "vite", | |
| "webpack", | |
| "rollup", | |
| "esbuild", | |
| "next", | |
| "nuxt", | |
| "svelte", | |
| "vue", | |
| "angular", | |
| "tailwindcss", | |
| "postcss", | |
| "sharp", | |
| "puppeteer", | |
| "playwright", | |
| "prisma", | |
| "sequelize", | |
| "knex", | |
| "typeorm", | |
| "graphql", | |
| "apollo-server", | |
| "fastify", | |
| "koa", | |
| "hapi", | |
| "nest" | |
| ]; | |
| function readJson(path) { | |
| try { | |
| return JSON.parse((0, import_node_fs17.readFileSync)(path, "utf8")); | |
| } catch { | |
| return void 0; | |
| } | |
| } | |
| function levenshtein(a, b) { | |
| if (Math.abs(a.length - b.length) > 1) return 2; | |
| const prev = new Array(b.length + 1); | |
| const cur = new Array(b.length + 1); | |
| for (let j = 0; j <= b.length; j++) prev[j] = j; | |
| for (let i = 1; i <= a.length; i++) { | |
| cur[0] = i; | |
| for (let j = 1; j <= b.length; j++) { | |
| const cost = a[i - 1] === b[j - 1] ? 0 : 1; | |
| cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + cost); | |
| } | |
| for (let j = 0; j <= b.length; j++) prev[j] = cur[j]; | |
| } | |
| return prev[b.length]; | |
| } | |
| function collectInstalledPackages(root, cap = 8e3) { | |
| const out = []; | |
| const visited = /* @__PURE__ */ new Set(); | |
| const seenPackages = /* @__PURE__ */ new Set(); | |
| const addPackage = (dir) => { | |
| let realDir; | |
| try { | |
| realDir = (0, import_node_fs17.realpathSync)(dir); | |
| } catch { | |
| return; | |
| } | |
| if (seenPackages.has(realDir)) return; | |
| seenPackages.add(realDir); | |
| const pkg = readJson(`${dir}/package.json`); | |
| if (!pkg || typeof pkg.name !== "string") return; | |
| const scripts = pkg.scripts ?? {}; | |
| const installScripts = ["preinstall", "install", "postinstall", "prepare"].filter( | |
| (s) => typeof scripts[s] === "string" && scripts[s].trim() !== "" | |
| ); | |
| out.push({ | |
| name: pkg.name, | |
| version: typeof pkg.version === "string" ? pkg.version : "?", | |
| dir, | |
| installScripts | |
| }); | |
| }; | |
| const walk = (nmDir, depth) => { | |
| if (out.length >= cap || depth > 4) return; | |
| let real; | |
| try { | |
| real = (0, import_node_fs17.realpathSync)(nmDir); | |
| } catch { | |
| return; | |
| } | |
| if (visited.has(real)) return; | |
| visited.add(real); | |
| let entries; | |
| try { | |
| entries = (0, import_node_fs17.readdirSync)(nmDir, { withFileTypes: true }); | |
| } catch { | |
| return; | |
| } | |
| for (const ent of entries) { | |
| if (out.length >= cap) return; | |
| const name = ent.name; | |
| if (name === ".bin") continue; | |
| const full = `${nmDir}/${name}`; | |
| if (name === ".pnpm") { | |
| let stores = []; | |
| try { | |
| stores = (0, import_node_fs17.readdirSync)(full); | |
| } catch { | |
| continue; | |
| } | |
| for (const s of stores) { | |
| if (out.length >= cap) return; | |
| walk(`${full}/${s}/node_modules`, depth + 1); | |
| } | |
| continue; | |
| } | |
| if (name.startsWith(".")) continue; | |
| if (name.startsWith("@")) { | |
| let scoped = []; | |
| try { | |
| scoped = (0, import_node_fs17.readdirSync)(full); | |
| } catch { | |
| continue; | |
| } | |
| for (const s of scoped) { | |
| if (out.length >= cap) return; | |
| addPackage(`${full}/${s}`); | |
| if ((0, import_node_fs17.existsSync)(`${full}/${s}/node_modules`)) walk(`${full}/${s}/node_modules`, depth + 1); | |
| } | |
| continue; | |
| } | |
| addPackage(full); | |
| if ((0, import_node_fs17.existsSync)(`${full}/node_modules`)) walk(`${full}/node_modules`, depth + 1); | |
| } | |
| }; | |
| walk(`${root}/node_modules`, 0); | |
| return out; | |
| } | |
| function auditCommand(target) { | |
| switch (target.pm) { | |
| case "npm": | |
| return { cmd: "npm", args: ["audit", "--json", "--omit=dev"] }; | |
| case "pnpm": | |
| return { cmd: "pnpm", args: ["audit", "--json", "--prod"] }; | |
| case "yarn": { | |
| const v = execCaptureAs(target.user, "yarn", ["--version"], { cwd: target.dir, timeoutMs: 3e4 }).stdout.trim(); | |
| const major = parseInt(v.split(".")[0] ?? "1", 10); | |
| return major >= 2 ? { cmd: "yarn", args: ["npm", "audit", "--json", "--environment", "production"] } : { cmd: "yarn", args: ["audit", "--json", "--groups", "dependencies"] }; | |
| } | |
| case "bun": | |
| return { cmd: "bun", args: ["audit", "--json"] }; | |
| default: | |
| return void 0; | |
| } | |
| } | |
| function normalizeSeverity(s) { | |
| const v = String(s ?? "").toLowerCase(); | |
| if (v === "critical") return "critical"; | |
| if (v === "high") return "high"; | |
| if (v === "moderate" || v === "medium") return "medium"; | |
| if (v === "low") return "low"; | |
| return "info"; | |
| } | |
| function parseAuditOutput(raw) { | |
| const counts = {}; | |
| const top = []; | |
| const bump = (s) => { | |
| counts[s] = (counts[s] ?? 0) + 1; | |
| }; | |
| const ingestV2 = (vulns) => { | |
| for (const [name, v] of Object.entries(vulns)) { | |
| const sev = normalizeSeverity(v?.severity); | |
| bump(sev); | |
| const via = Array.isArray(v?.via) ? v.via.find((x) => x && typeof x === "object") : void 0; | |
| top.push({ | |
| name, | |
| severity: sev, | |
| title: String(via?.title ?? v?.title ?? "l\u1ED7 h\u1ED5ng \u0111\xE3 c\xF4ng b\u1ED1"), | |
| fixAvailable: Boolean(v?.fixAvailable) | |
| }); | |
| } | |
| }; | |
| const ingestAdvisories = (advs) => { | |
| for (const a of Object.values(advs)) { | |
| const sev = normalizeSeverity(a?.severity); | |
| bump(sev); | |
| top.push({ | |
| name: String(a?.module_name ?? a?.name ?? "?"), | |
| severity: sev, | |
| title: String(a?.title ?? "l\u1ED7 h\u1ED5ng \u0111\xE3 c\xF4ng b\u1ED1"), | |
| fixAvailable: Boolean(a?.patched_versions && a.patched_versions !== "<0.0.0") | |
| }); | |
| } | |
| }; | |
| const text = raw.trim(); | |
| if (!text) return { counts, top, error: "audit kh\xF4ng tr\u1EA3 v\u1EC1 d\u1EEF li\u1EC7u" }; | |
| try { | |
| const doc = JSON.parse(text); | |
| if (doc && typeof doc === "object") { | |
| if (doc.vulnerabilities && !Array.isArray(doc.vulnerabilities) && typeof doc.vulnerabilities === "object") { | |
| const looksLikeCounts = Object.values(doc.vulnerabilities).every((v) => typeof v === "number"); | |
| if (!looksLikeCounts) ingestV2(doc.vulnerabilities); | |
| } | |
| if (doc.advisories && typeof doc.advisories === "object") ingestAdvisories(doc.advisories); | |
| } | |
| } catch { | |
| for (const line of text.split("\n")) { | |
| const t = line.trim(); | |
| if (!t.startsWith("{")) continue; | |
| try { | |
| const doc = JSON.parse(t); | |
| if (doc?.type === "auditAdvisory" && doc?.data?.advisory) { | |
| ingestAdvisories({ x: doc.data.advisory }); | |
| } | |
| } catch { | |
| } | |
| } | |
| } | |
| if (top.length === 0 && Object.keys(counts).length === 0) { | |
| return { counts, top }; | |
| } | |
| top.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]); | |
| return { counts, top }; | |
| } | |
| function runAudit(target) { | |
| const spec = auditCommand(target); | |
| if (!spec) return { counts: {}, top: [], error: `ch\u01B0a h\u1ED7 tr\u1EE3 audit cho '${target.pm}'` }; | |
| const res = execCaptureAs(target.user, spec.cmd, spec.args, { | |
| cwd: target.dir, | |
| timeoutMs: 18e4, | |
| // Tắt màu/tiến trình để output chỉ còn JSON thuần. | |
| env: { NO_COLOR: "1", NPM_CONFIG_FUND: "false", NPM_CONFIG_AUDIT_LEVEL: "info", CI: "1" } | |
| }); | |
| const parsed = parseAuditOutput(res.stdout); | |
| if (parsed.top.length === 0 && !res.stdout.trim()) { | |
| const err = (res.stderr || "").split("\n").filter(Boolean).slice(0, 2).join(" "); | |
| return { counts: {}, top: [], error: err || `kh\xF4ng ch\u1EA1y \u0111\u01B0\u1EE3c '${spec.cmd} ${spec.args.join(" ")}' (m\xE3 ${res.code})` }; | |
| } | |
| return parsed; | |
| } | |
| function scanProject(target, opts = { audit: true }) { | |
| const findings = []; | |
| const dir = target.dir; | |
| if (!(0, import_node_fs17.existsSync)(`${dir}/package.json`)) { | |
| return { target, findings, packageCount: 0, skipped: "kh\xF4ng c\xF3 package.json" }; | |
| } | |
| const pkg = readJson(`${dir}/package.json`); | |
| if (!pkg) { | |
| findings.push({ | |
| severity: "medium", | |
| title: "package.json kh\xF4ng \u0111\u1ECDc \u0111\u01B0\u1EE3c", | |
| detail: `${dir}/package.json kh\xF4ng ph\u1EA3i JSON h\u1EE3p l\u1EC7 \u2014 kh\xF4ng qu\xE9t \u0111\u01B0\u1EE3c khai b\xE1o dependency.`, | |
| fix: "S\u1EEDa l\u1EA1i c\xFA ph\xE1p package.json r\u1ED3i qu\xE9t l\u1EA1i." | |
| }); | |
| return { target, findings, packageCount: 0 }; | |
| } | |
| const deps = { | |
| ...pkg.dependencies ?? {}, | |
| ...pkg.optionalDependencies ?? {} | |
| }; | |
| const devDeps = pkg.devDependencies ?? {}; | |
| const expected = LOCKFILES[target.pm] ?? []; | |
| const hasOwnLock = expected.some((f) => (0, import_node_fs17.existsSync)(`${dir}/${f}`)); | |
| const foreignLocks = ALL_LOCKFILES.filter((f) => !expected.includes(f) && (0, import_node_fs17.existsSync)(`${dir}/${f}`)); | |
| if (!hasOwnLock) { | |
| findings.push({ | |
| severity: "high", | |
| title: `Thi\u1EBFu lockfile cho ${target.pm} (${expected.join(" / ")})`, | |
| detail: "Kh\xF4ng c\xF3 lockfile ngh\u0129a l\xE0 m\u1ED7i l\u1EA7n deploy s\u1EBD gi\u1EA3i l\u1EA1i d\u1EA3i phi\xEAn b\u1EA3n v\xE0 c\xF3 th\u1EC3 k\xE9o v\u1EC1 b\u1EA3n M\u1EDAI v\u1EEBa publish. \u0110\xE2y ch\xEDnh l\xE0 \u0111\u01B0\u1EDDng v\xE0o c\u1EE7a t\u1EA5n c\xF4ng chu\u1ED7i cung \u1EE9ng: b\u1EA3n \u0111\u1ED9c ph\xE1t h\xE0nh l\xFAc 2h s\xE1ng s\u1EBD t\u1EF1 chui v\xE0o l\u1EA7n deploy k\u1EBF ti\u1EBFp.", | |
| fix: `Tr\xEAn m\xE1y dev: ch\u1EA1y '${target.pm} install' \u0111\u1EC3 sinh lockfile, commit v\xE0o repo, r\u1ED3i 'napp app deploy' / 'napp service deploy'. | |
| Lockfile ph\u1EA3i \u0111\u01B0\u1EE3c COMMIT \u2014 \u0111\xE2y l\xE0 b\u1EA3n ghi ch\xEDnh x\xE1c t\u1EEBng phi\xEAn b\u1EA3n + hash to\xE0n v\u1EB9n c\u1EE7a to\xE0n b\u1ED9 c\xE2y ph\u1EE5 thu\u1ED9c.` | |
| }); | |
| } | |
| if (foreignLocks.length > 0) { | |
| findings.push({ | |
| severity: "low", | |
| title: `C\xF3 lockfile c\u1EE7a tr\xECnh qu\u1EA3n l\xFD g\xF3i kh\xE1c: ${foreignLocks.join(", ")}`, | |
| detail: `D\u1EF1 \xE1n \u0111ang c\xE0i b\u1EB1ng ${target.pm} nh\u01B0ng repo c\xF2n lockfile c\u1EE7a tr\xECnh kh\xE1c \u2014 d\u1EC5 g\xE2y hi\u1EC3u nh\u1EA7m v\xE0 c\xE0i ra hai c\xE2y ph\u1EE5 thu\u1ED9c kh\xE1c nhau gi\u1EEFa dev v\xE0 server.`, | |
| fix: `Xo\xE1 lockfile th\u1EEBa kh\u1ECFi repo, ch\u1EC9 gi\u1EEF ${expected[0] ?? "lockfile \u0111\xFAng tr\xECnh"}.` | |
| }); | |
| } | |
| const wildcard = []; | |
| const remote = []; | |
| const loose = []; | |
| for (const [name, spec] of Object.entries({ ...deps, ...devDeps })) { | |
| if (typeof spec !== "string") continue; | |
| const s = spec.trim(); | |
| if (/^(\*|x|latest|)$/i.test(s)) wildcard.push(`${name}@${s || "(r\u1ED7ng)"}`); | |
| else if (/^(git\+|git:|github:|gitlab:|bitbucket:|https?:)/i.test(s)) remote.push(`${name} -> ${s}`); | |
| else if (/^[\^~]|^>=|\s-\s|\|\|/.test(s)) loose.push(`${name}@${s}`); | |
| } | |
| if (wildcard.length > 0) { | |
| findings.push({ | |
| severity: "high", | |
| title: `${wildcard.length} dependency kh\xF4ng ghim phi\xEAn b\u1EA3n ('*' / 'latest')`, | |
| detail: `Lu\xF4n l\u1EA5y b\u1EA3n m\u1EDBi nh\u1EA5t t\u1EA1i th\u1EDDi \u0111i\u1EC3m c\xE0i: ${wildcard.slice(0, 8).join(", ")}${wildcard.length > 8 ? ", ..." : ""}`, | |
| fix: '\u0110\u1ED5i sang phi\xEAn b\u1EA3n c\u1EE5 th\u1EC3 (vd "4.19.2") ho\u1EB7c d\u1EA3i h\u1EB9p ("^4.19.2") K\xC8M lockfile \u0111\xE3 commit.' | |
| }); | |
| } | |
| if (remote.length > 0) { | |
| findings.push({ | |
| severity: "high", | |
| title: `${remote.length} dependency tr\u1ECF th\u1EB3ng t\u1EDBi git/URL`, | |
| detail: `Kh\xF4ng \u0111i qua registry n\xEAn KH\xD4NG c\xF3 hash to\xE0n v\u1EB9n: ch\u1EE7 repo (ho\u1EB7c ai chi\u1EBFm \u0111\u01B0\u1EE3c) \u0111\u1ED5i n\u1ED9i dung branch/tag l\xE0 m\xE3 \u0111\u1ED5i theo m\xE0 lockfile kh\xF4ng ph\xE1t hi\u1EC7n. | |
| ${remote.slice(0, 6).join("\n ")}`, | |
| fix: "Ghim theo commit SHA \u0111\u1EA7y \u0111\u1EE7 (vd 'github:user/repo#<sha40>') thay v\xEC branch/tag, ho\u1EB7c publish n\u1ED9i b\u1ED9 l\xEAn registry ri\xEAng." | |
| }); | |
| } | |
| if (loose.length > 0 && !hasOwnLock) { | |
| findings.push({ | |
| severity: "medium", | |
| title: `${loose.length} dependency d\xF9ng d\u1EA3i phi\xEAn b\u1EA3n m\u1EDF m\xE0 KH\xD4NG c\xF3 lockfile`, | |
| detail: `V\xED d\u1EE5: ${loose.slice(0, 6).join(", ")}${loose.length > 6 ? ", ..." : ""}`, | |
| fix: "Commit lockfile (xem m\u1EE5c thi\u1EBFu lockfile \u1EDF tr\xEAn) \u2014 c\xF3 lockfile th\xEC d\u1EA3i m\u1EDF kh\xF4ng c\xF2n nguy hi\u1EC3m." | |
| }); | |
| } | |
| const installed = collectInstalledPackages(dir); | |
| const withScripts = installed.filter((p) => p.installScripts.some((s) => s !== "prepare")); | |
| if (withScripts.length > 0) { | |
| const list = withScripts.slice(0, 10).map((p) => `${p.name}@${p.version} (${p.installScripts.join(",")})`); | |
| findings.push({ | |
| severity: withScripts.length > 15 ? "medium" : "low", | |
| title: `${withScripts.length} package ch\u1EA1y script khi c\xE0i \u0111\u1EB7t`, | |
| detail: `Script preinstall/install/postinstall ch\u1EA1y v\u1EDBi quy\u1EC1n user c\u1EE7a app NGAY khi c\xE0i \u2014 l\xE0 b\u01B0\u1EDBc th\u1EF1c thi \u0111\u1EA7u ti\xEAn c\u1EE7a m\u1ECDi package b\u1ECB chi\u1EBFm. Nhi\u1EC1u package h\u1EE3p l\u1EC7 c\u0169ng d\xF9ng (bi\xEAn d\u1ECBch native, t\u1EA3i binary), n\xEAn \u0111\xE2y l\xE0 danh s\xE1ch C\u1EA6N R\xC0, kh\xF4ng ph\u1EA3i k\u1EBFt lu\u1EADn: | |
| ${list.join("\n ")}` + (withScripts.length > 10 ? ` | |
| ... v\xE0 ${withScripts.length - 10} package n\u1EEFa` : ""), | |
| fix: `R\xE0 t\u1EEBng c\xE1i xem c\xF3 \u0111\xFAng l\xE0 package c\u1EA7n bi\xEAn d\u1ECBch/t\u1EA3i binary kh\xF4ng. | |
| Mu\u1ED1n ch\u1EB7n h\u1EB3n: \u0111\u1EB7t install-cmd c\xF3 '--ignore-scripts' (npm/pnpm/yarn/bun \u0111\u1EC1u h\u1ED7 tr\u1EE3), vd | |
| napp app create ... --install-cmd 'npm ci --omit=dev --ignore-scripts' | |
| L\u01B0u \xFD: package c\u1EA7n bi\xEAn d\u1ECBch native (bcrypt, sharp, better-sqlite3...) s\u1EBD h\u1ECFng n\u1EBFu ch\u1EB7n \u2014 h\xE3y thay b\u1EB1ng b\u1EA3n thu\u1EA7n JS ho\u1EB7c build s\u1EB5n.` | |
| }); | |
| } | |
| const suspicious = []; | |
| for (const name of Object.keys(deps)) { | |
| if (name.startsWith("@") || name.length < 4) continue; | |
| for (const popular of POPULAR_PACKAGES) { | |
| if (name === popular) break; | |
| if (Math.abs(name.length - popular.length) <= 1 && levenshtein(name, popular) === 1) { | |
| suspicious.push(`${name} (gi\u1ED1ng '${popular}')`); | |
| break; | |
| } | |
| } | |
| } | |
| if (suspicious.length > 0) { | |
| findings.push({ | |
| severity: "high", | |
| title: `${suspicious.length} t\xEAn package g\u1EA7n gi\u1ED1ng package ph\u1ED5 bi\u1EBFn (nghi typosquat)`, | |
| detail: `K\u1EBB t\u1EA5n c\xF4ng publish t\xEAn sai m\u1ED9t k\xFD t\u1EF1 \u0111\u1EC3 \u0103n theo l\u1ED7i g\xF5 ph\xEDm: ${suspicious.join(", ")}`, | |
| fix: "\u0110\u1ED1i chi\u1EBFu t\xEAn v\u1EDBi trang ch\xEDnh th\u1EE9c c\u1EE7a th\u01B0 vi\u1EC7n. N\u1EBFu g\xF5 nh\u1EA7m: g\u1EE1 package \u0111\xF3, c\xE0i l\u1EA1i \u0111\xFAng t\xEAn, \u0111\u1ED5i m\u1ECDi secret m\xE0 app d\xF9ng (m\xE3 l\u1EA1 \u0111\xE3 c\xF3 th\u1EC3 \u0111\u1ECDc .env)." | |
| }); | |
| } | |
| const npmrc = `${dir}/.npmrc`; | |
| if ((0, import_node_fs17.existsSync)(npmrc)) { | |
| let content = ""; | |
| try { | |
| content = (0, import_node_fs17.readFileSync)(npmrc, "utf8"); | |
| } catch { | |
| } | |
| if (/_auth(Token)?\s*=/.test(content)) { | |
| let mode = 0; | |
| try { | |
| mode = (0, import_node_fs17.statSync)(npmrc).mode & 511; | |
| } catch { | |
| } | |
| const tooOpen = (mode & 63) !== 0; | |
| findings.push({ | |
| severity: tooOpen ? "high" : "low", | |
| title: `.npmrc ch\u1EE9a token registry${tooOpen ? ` v\xE0 quy\u1EC1n qu\xE1 r\u1ED9ng (${mode.toString(8)})` : ""}`, | |
| detail: `${npmrc} c\xF3 _authToken. Token n\xE0y th\u01B0\u1EDDng c\xF3 quy\u1EC1n \u0110\u1ECCC (\u0111\xF4i khi c\u1EA3 PUBLISH) tr\xEAn registry ri\xEAng c\u1EE7a b\u1EA1n.`, | |
| fix: tooOpen ? `sudo chmod 600 ${npmrc} && sudo chown ${target.user}:${target.user} ${npmrc}` : "C\xE2n nh\u1EAFc d\xF9ng token ch\u1EC9-\u0111\u1ECDc ri\xEAng cho m\xE1y ch\u1EE7, v\xE0 xoay v\xF2ng \u0111\u1ECBnh k\u1EF3." | |
| }); | |
| } | |
| } | |
| if (opts.audit) { | |
| const audit = runAudit(target); | |
| if (audit.error) { | |
| findings.push({ | |
| severity: "info", | |
| title: "Kh\xF4ng ch\u1EA1y \u0111\u01B0\u1EE3c audit l\u1ED7 h\u1ED5ng", | |
| detail: audit.error, | |
| fix: `Ch\u1EA1y tay \u0111\u1EC3 xem chi ti\u1EBFt: sudo -u ${target.user} bash -lc 'cd ${target.dir} && ${target.pm} audit'` | |
| }); | |
| } else { | |
| const bad = audit.top.filter((v) => v.severity === "critical" || v.severity === "high"); | |
| const total = Object.values(audit.counts).reduce((a, b) => a + b, 0); | |
| if (bad.length > 0) { | |
| findings.push({ | |
| severity: bad.some((v) => v.severity === "critical") ? "critical" : "high", | |
| title: `${bad.length} l\u1ED7 h\u1ED5ng nghi\xEAm tr\u1ECDng/cao trong dependencies (t\u1ED5ng ${total})`, | |
| detail: bad.slice(0, 8).map((v) => `${v.name} [${v.severity}] ${v.title}${v.fixAvailable ? " \u2014 C\xD3 b\u1EA3n v\xE1" : " \u2014 ch\u01B0a c\xF3 b\u1EA3n v\xE1"}`).join("\n "), | |
| fix: `Tr\xEAn m\xE1y dev: '${target.pm} audit' \u0111\u1EC3 xem chi ti\u1EBFt, '${target.pm === "npm" ? "npm audit fix" : `${target.pm} update`}' \u0111\u1EC3 n\xE2ng c\u1EA5p, commit lockfile m\u1EDBi r\u1ED3i deploy l\u1EA1i. | |
| Package ch\u01B0a c\xF3 b\u1EA3n v\xE1: c\xE2n nh\u1EAFc thay th\u01B0 vi\u1EC7n kh\xE1c ho\u1EB7c kho\xE1 \u0111\u01B0\u1EDDng \u0111i t\u1EDBi \u0111o\u1EA1n m\xE3 b\u1ECB \u1EA3nh h\u01B0\u1EDFng.` | |
| }); | |
| } else if (total > 0) { | |
| findings.push({ | |
| severity: "low", | |
| title: `${total} l\u1ED7 h\u1ED5ng m\u1EE9c th\u1EA5p/trung b\xECnh trong dependencies`, | |
| detail: audit.top.slice(0, 5).map((v) => `${v.name} [${v.severity}] ${v.title}`).join("\n "), | |
| fix: `N\xE2ng c\u1EA5p khi ti\u1EC7n: '${target.pm} audit' tr\xEAn m\xE1y dev, commit lockfile m\u1EDBi r\u1ED3i deploy.` | |
| }); | |
| } | |
| } | |
| } | |
| findings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]); | |
| return { target, findings, packageCount: installed.length }; | |
| } | |
| async function checkFreshReleases(target, opts = {}) { | |
| const maxPackages = opts.maxPackages ?? 40; | |
| const freshDays = opts.freshDays ?? 14; | |
| const pkg = readJson(`${target.dir}/package.json`); | |
| if (!pkg) return { fresh: [], checked: 0, error: "kh\xF4ng \u0111\u1ECDc \u0111\u01B0\u1EE3c package.json" }; | |
| const direct = Object.keys(pkg.dependencies ?? {}); | |
| if (direct.length === 0) return { fresh: [], checked: 0 }; | |
| const names = direct.slice(0, maxPackages); | |
| const installedVersion = (name) => { | |
| const p = readJson(`${target.dir}/node_modules/${name}/package.json`); | |
| return typeof p?.version === "string" ? p.version : void 0; | |
| }; | |
| const fresh = []; | |
| let checked = 0; | |
| let firstError; | |
| const CONCURRENCY = 6; | |
| let cursor = 0; | |
| const worker = async () => { | |
| while (cursor < names.length) { | |
| const name = names[cursor++]; | |
| const version = installedVersion(name); | |
| if (!version) continue; | |
| try { | |
| const res = await fetch(`https://registry.npmjs.org/${name.replace(/\//g, "%2f")}`, { | |
| headers: { accept: "application/vnd.npm.install-v1+json" }, | |
| signal: AbortSignal.timeout(opts.timeoutMs ?? 15e3) | |
| }); | |
| if (!res.ok) { | |
| firstError ??= `registry tr\u1EA3 ${res.status} cho '${name}'`; | |
| continue; | |
| } | |
| const doc = await res.json(); | |
| checked++; | |
| const latest = doc["dist-tags"]?.latest; | |
| const stamp2 = doc.time?.[version] ?? doc.modified; | |
| if (!stamp2) continue; | |
| const ageDays = (Date.now() - new Date(stamp2).getTime()) / 864e5; | |
| if (ageDays <= freshDays) { | |
| fresh.push({ | |
| name, | |
| installedVersion: version, | |
| lastPublishISO: stamp2, | |
| ageDays: Math.max(0, Math.round(ageDays * 10) / 10), | |
| isLatest: latest === version | |
| }); | |
| } | |
| } catch (e) { | |
| firstError ??= e.message; | |
| } | |
| } | |
| }; | |
| await Promise.all(Array.from({ length: Math.min(CONCURRENCY, names.length) }, worker)); | |
| fresh.sort((a, b) => a.ageDays - b.ageDays); | |
| return { fresh, checked, error: checked === 0 ? firstError : void 0 }; | |
| } | |
| // src/commands/doctor.ts | |
| var SEVERITY_LABEL = { | |
| critical: "NGHI\xCAM TR\u1ECCNG", | |
| high: "CAO", | |
| medium: "TRUNG B\xCCNH", | |
| low: "TH\u1EA4P", | |
| info: "TH\xD4NG TIN" | |
| }; | |
| var SEVERITY_COLOR = { | |
| critical: "redBold", | |
| high: "red", | |
| medium: "yellow", | |
| low: "blue", | |
| info: "dim" | |
| }; | |
| var SEVERITY_TAG_WIDTH = 14; | |
| function printSeverity(sev, text) { | |
| const color = SEVERITY_COLOR[sev]; | |
| const tag = colorText(color, `[${SEVERITY_LABEL[sev]}]`.padEnd(SEVERITY_TAG_WIDTH)); | |
| const body = sev === "critical" || sev === "high" ? colorText(color, text) : text; | |
| console.log(` ${tag} ${body}`); | |
| } | |
| async function confirm2(question, autoYes) { | |
| if (autoYes) return true; | |
| const readline2 = await import("node:readline/promises"); | |
| const rl2 = readline2.createInterface({ input: process.stdin, output: process.stdout }); | |
| const ans = await rl2.question(`${question} [y/N] `); | |
| rl2.close(); | |
| return /^y(es)?$/i.test(ans.trim()); | |
| } | |
| function managedUnits() { | |
| const st = loadState(); | |
| return [ | |
| ...["nginx", "mariadb", "mysql", "redis-server", "ssh", "sshd", "fail2ban"].filter((u) => isServiceActive(u)), | |
| ...Object.values(st.apps).map((a) => `${serviceNameFor(a.domain)}.service`), | |
| ...Object.values(st.services).map((s) => `${svcSystemdName(s.name)}.service`) | |
| ]; | |
| } | |
| function nginxUpstreamVersion() { | |
| if (!commandExists("nginx")) return void 0; | |
| const res = execCapture("nginx", ["-v"]); | |
| const m = `${res.stderr}${res.stdout}`.match(/nginx\/(\d+\.\d+\.\d+)/); | |
| return m?.[1]; | |
| } | |
| function nginxPackageInfo() { | |
| for (const pkg of ["nginx-core", "nginx-full", "nginx-light", "nginx-extras", "nginx", "nginx-common"]) { | |
| const res = execCapture("dpkg-query", ["-W", "-f=${Version}", pkg]); | |
| if (res.code === 0 && res.stdout.trim()) return { pkg, version: res.stdout.trim() }; | |
| } | |
| return void 0; | |
| } | |
| function nginxChangelog() { | |
| const candidates = []; | |
| for (const dir of ["nginx-common", "nginx-core", "nginx", "nginx-full", "nginx-light", "nginx-extras"]) { | |
| candidates.push(`/usr/share/doc/${dir}/changelog.Debian.gz`, `/usr/share/doc/${dir}/changelog.gz`, `/usr/share/doc/${dir}/changelog.Debian`); | |
| } | |
| for (const path of candidates) { | |
| if (!(0, import_node_fs18.existsSync)(path)) continue; | |
| try { | |
| const raw = (0, import_node_fs18.readFileSync)(path); | |
| const text = path.endsWith(".gz") ? (0, import_node_zlib.gunzipSync)(raw).toString("utf8") : raw.toString("utf8"); | |
| if (text.trim()) return { text, path }; | |
| } catch { | |
| } | |
| } | |
| return void 0; | |
| } | |
| function nginxRunningConfig() { | |
| if (!commandExists("nginx")) return void 0; | |
| const res = execCapture("nginx", ["-T"]); | |
| if (res.code !== 0 || !res.stdout.trim()) return void 0; | |
| return res.stdout; | |
| } | |
| function collectSystemReport(opts) { | |
| if (opts.refresh) { | |
| if (process.getuid && process.getuid() !== 0) { | |
| warn("B\u1ECF qua l\xE0m m\u1EDBi ch\u1EC9 m\u1EE5c apt (c\u1EA7n quy\u1EC1n root) \u2014 k\u1EBFt qu\u1EA3 d\u1EF1a tr\xEAn ch\u1EC9 m\u1EE5c \u0111\xE3 c\xF3 s\u1EB5n, c\xF3 th\u1EC3 c\u0169."); | |
| } else { | |
| info("\u0110ang l\xE0m m\u1EDBi ch\u1EC9 m\u1EE5c g\xF3i (apt-get update)..."); | |
| runCmd("apt-get", ["update", "-qq"], { silentFail: true }); | |
| } | |
| } | |
| const pending = pendingUpdates(); | |
| const security = pending.filter((p) => p.security); | |
| const other = pending.filter((p) => !p.security); | |
| const staleUnits = []; | |
| for (const unit of managedUnits()) { | |
| const libs = unitStaleLibraries(unit); | |
| if (libs && libs.length > 0) staleUnits.push({ unit, libs }); | |
| } | |
| return { security, other, staleUnits, reboot: rebootRequired() }; | |
| } | |
| function cmdDoctorSystem(opts) { | |
| section("Ki\u1EC3m tra b\u1EA3n v\xE1 b\u1EA3o m\u1EADt c\u1EE7a h\u1EC7 th\u1ED1ng"); | |
| if (!hasApt()) { | |
| warn("Kh\xF4ng t\xECm th\u1EA5y apt-get \u2014 ph\u1EA7n ki\u1EC3m tra b\u1EA3n v\xE1 ch\u1EC9 h\u1ED7 tr\u1EE3 Ubuntu/Debian. B\u1ECF qua."); | |
| return void 0; | |
| } | |
| const report = collectSystemReport(opts); | |
| const age = aptIndexAgeSeconds(); | |
| if (age !== void 0 && age > 86400 * 2) { | |
| warn( | |
| `Ch\u1EC9 m\u1EE5c g\xF3i \u0111\xE3 c\u0169 ${Math.floor(age / 86400)} ng\xE0y \u2014 danh s\xE1ch b\u1EA3n v\xE1 d\u01B0\u1EDBi \u0111\xE2y c\xF3 th\u1EC3 THI\u1EBEU. Ch\u1EA1y 'sudo napp doctor system' (t\u1EF1 l\xE0m m\u1EDBi) ho\u1EB7c 'sudo apt-get update'.` | |
| ); | |
| } | |
| if (report.security.length === 0) { | |
| ok("Kh\xF4ng c\xF3 b\u1EA3n v\xE1 b\u1EA3o m\u1EADt n\xE0o \u0111ang ch\u1EDD c\xE0i."); | |
| } else { | |
| const critical = report.security.filter((p) => isCriticalPackage(p.pkg)); | |
| danger(`C\xF3 ${report.security.length} g\xF3i C\xD3 B\u1EA2N V\xC1 B\u1EA2O M\u1EACT \u0111ang ch\u1EDD c\xE0i${critical.length ? ` (${critical.length} g\xF3i TR\u1ECCNG Y\u1EBEU)` : ""}:`); | |
| for (const p of report.security.slice(0, 25)) { | |
| const line = `${p.pkg.padEnd(28)} ${p.currentVersion || "(m\u1EDBi)"} -> ${p.newVersion}`; | |
| if (isCriticalPackage(p.pkg)) console.log(` ${colorText("redBold", "!")} ${colorText("red", line)}`); | |
| else console.log(` ${colorText("dim", line)}`); | |
| } | |
| if (report.security.length > 25) console.log(` ${colorText("dim", `... v\xE0 ${report.security.length - 25} g\xF3i n\u1EEFa`)}`); | |
| console.log(); | |
| info("C\xE0i c\xE1c b\u1EA3n v\xE1 n\xE0y: sudo napp doctor upgrade (ch\u1EC9 c\xE0i b\u1EA3n v\xE1 b\u1EA3o m\u1EADt, t\u1EF1 restart d\u1ECBch v\u1EE5 li\xEAn quan)"); | |
| } | |
| if (report.other.length > 0) { | |
| info(`Ngo\xE0i ra c\xF3 ${report.other.length} g\xF3i c\xF3 b\u1EA3n c\u1EADp nh\u1EADt th\u01B0\u1EDDng (kh\xF4ng ph\u1EA3i b\u1EA3n v\xE1 b\u1EA3o m\u1EADt) \u2014 c\xE0i b\u1EB1ng 'sudo napp doctor upgrade --all'.`); | |
| } | |
| if (report.staleUnits.length > 0) { | |
| console.log(); | |
| danger(`${report.staleUnits.length} d\u1ECBch v\u1EE5 v\u1EABn \u0111ang ch\u1EA1y TH\u01AF VI\u1EC6N C\u0168 \u0111\xE3 b\u1ECB thay tr\xEAn \u0111\u0129a \u2014 b\u1EA3n v\xE1 CH\u01AFA c\xF3 hi\u1EC7u l\u1EF1c v\u1EDBi ch\xFAng:`); | |
| for (const s of report.staleUnits) { | |
| step(`${s.unit}: ${s.libs.slice(0, 3).join(", ")}${s.libs.length > 3 ? ` (+${s.libs.length - 3})` : ""}`); | |
| } | |
| info(`Kh\u1EAFc ph\u1EE5c: sudo systemctl restart ${report.staleUnits.map((s) => s.unit).join(" ")}`); | |
| } else if (process.getuid && process.getuid() === 0) { | |
| ok("Kh\xF4ng c\xF3 d\u1ECBch v\u1EE5 n\xE0o c\xF2n n\u1EA1p th\u01B0 vi\u1EC7n c\u0169 (b\u1EA3n v\xE1 \u0111\xE3 c\xE0i \u0111\u1EC1u \u0111\xE3 c\xF3 hi\u1EC7u l\u1EF1c)."); | |
| } else { | |
| info("B\u1ECF qua ki\u1EC3m tra 'd\u1ECBch v\u1EE5 c\xF2n n\u1EA1p th\u01B0 vi\u1EC7n c\u0169' \u2014 c\u1EA7n ch\u1EA1y b\u1EB1ng sudo \u0111\u1EC3 \u0111\u1ECDc \u0111\u01B0\u1EE3c /proc c\u1EE7a ti\u1EBFn tr\xECnh kh\xE1c."); | |
| } | |
| if (report.reboot.required) { | |
| console.log(); | |
| warn( | |
| `M\xE1y c\u1EA7n KH\u1EDEI \u0110\u1ED8NG L\u1EA0I \u0111\u1EC3 b\u1EA3n v\xE1 c\xF3 hi\u1EC7u l\u1EF1c (th\u01B0\u1EDDng l\xE0 nh\xE2n/kernel ho\u1EB7c libc)` + (report.reboot.packages.length ? `: ${report.reboot.packages.slice(0, 6).join(", ")}` : "") + ". H\xE3y h\u1EB9n m\u1ED9t khung gi\u1EDD \xEDt truy c\u1EADp r\u1ED3i 'sudo reboot'." | |
| ); | |
| } | |
| console.log(); | |
| const nginxVersion = nginxUpstreamVersion(); | |
| if (!nginxVersion) { | |
| info("Kh\xF4ng \u0111\u1ECDc \u0111\u01B0\u1EE3c phi\xEAn b\u1EA3n nginx (ch\u01B0a c\xE0i?) \u2014 b\u1ECF qua \u0111\u1ED1i chi\u1EBFu CVE."); | |
| } else { | |
| const nginxPatch = report.security.find((p) => /^nginx/.test(p.pkg)); | |
| const pkgInfo = nginxPackageInfo(); | |
| const changelog = nginxChangelog(); | |
| const config = nginxRunningConfig(); | |
| const buildFlags = `${execCapture("nginx", ["-V"]).stderr}${execCapture("nginx", ["-V"]).stdout}`; | |
| if (nginxPatch) { | |
| danger( | |
| `nginx ${nginxVersion} (g\xF3i ${nginxPatch.currentVersion}) C\xD3 B\u1EA2N V\xC1 B\u1EA2O M\u1EACT \u0110ANG CH\u1EDC -> ${nginxPatch.newVersion}. \u0110\xE2y l\xE0 t\xEDn hi\u1EC7u ch\u1EAFc ch\u1EAFn nh\u1EA5t: h\xE3y c\xE0i ngay b\u1EB1ng 'sudo napp doctor upgrade'.` | |
| ); | |
| } else { | |
| ok( | |
| `nginx ${nginxVersion}${pkgInfo ? ` (g\xF3i ${pkgInfo.pkg} ${pkgInfo.version})` : ""}: kh\xF4ng c\xF3 b\u1EA3n v\xE1 b\u1EA3o m\u1EADt n\xE0o \u0111ang ch\u1EDD t\u1EEB kho c\u1EE7a b\u1EA3n ph\xE2n ph\u1ED1i.` | |
| ); | |
| } | |
| const assessments = assessNginxCves(nginxVersion, { changelog: changelog?.text, buildFlags, config }); | |
| if (assessments.length > 0) { | |
| console.log(); | |
| const patched = assessments.filter((a) => a.verdict === "patched"); | |
| const na = assessments.filter((a) => a.verdict === "not-applicable"); | |
| const affected = assessments.filter((a) => a.verdict === "affected"); | |
| info( | |
| `\u0110\u1ED1i chi\u1EBFu ${assessments.length} CVE \u0111\xE1ng ch\xFA \xFD c\u1EE7a nh\xE1nh nginx ${nginxVersion} v\u1EDBi b\u1EB1ng ch\u1EE9ng tr\xEAn m\xE1y n\xE0y (changelog g\xF3i${changelog ? " \u2713" : " \u2717 kh\xF4ng \u0111\u1ECDc \u0111\u01B0\u1EE3c"}, c\u1EA5u h\xECnh \u0111ang ch\u1EA1y${config ? " \u2713" : " \u2717 c\u1EA7n sudo"}):` | |
| ); | |
| for (const a of patched) { | |
| console.log(` ${colorText("green", "[\u0110\xC3 V\xC1]".padEnd(SEVERITY_TAG_WIDTH))} ${a.cve.id} \u2014 ${a.reason}`); | |
| } | |
| for (const a of na) { | |
| console.log(` ${colorText("dim", "[KH\xD4NG D\xCDNH]".padEnd(SEVERITY_TAG_WIDTH))} ${colorText("dim", `${a.cve.id} \u2014 ${a.reason}`)}`); | |
| } | |
| for (const a of affected) { | |
| printSeverity(a.cve.severity === "critical" ? "critical" : a.cve.severity === "high" ? "high" : "medium", `${a.cve.id} \u2014 ${a.cve.summary}`); | |
| step(`V\xEC sao c\xF2n n\u1EB1m \u0111\xE2y: ${a.reason}`); | |
| if (a.cve.condition) step(a.cve.condition); | |
| step(`Upstream v\xE1 \u1EDF: ${a.cve.fixedIn.join(" / ")}`); | |
| } | |
| console.log(); | |
| if (affected.length === 0) { | |
| ok(`C\u1EA3 ${assessments.length} CVE \u0111\u1EC1u \u0111\xE3 \u0111\u01B0\u1EE3c v\xE1 ho\u1EB7c kh\xF4ng v\u1EDBi t\u1EDBi \u0111\u01B0\u1EE3c m\xE1y n\xE0y \u2014 kh\xF4ng c\u1EA7n l\xE0m g\xEC th\xEAm.`); | |
| } else { | |
| info( | |
| `C\xF2n ${affected.length} CVE ch\u01B0a ch\u1EE9ng minh \u0111\u01B0\u1EE3c l\xE0 \u0111\xE3 x\u1EED l\xFD. Ki\u1EC3m ch\u1EE9ng th\u1EE7 c\xF4ng: | |
| ` + (changelog ? ` zgrep -i '${affected[0].cve.cveIds[0]}' ${changelog.path} | |
| ` : ` apt changelog nginx | head -40 | |
| `) + ` https://ubuntu.com/security/${affected[0].cve.cveIds[0]} (tra b\u1EA3n ph\xE2n ph\u1ED1i \u0111\xE3 v\xE1 \u1EDF phi\xEAn b\u1EA3n g\xF3i n\xE0o)` | |
| ); | |
| info( | |
| "N\u1EBFu kho c\u1EE7a b\u1EA3n ph\xE2n ph\u1ED1i KH\xD4NG c\xF2n ph\xE1t h\xE0nh b\u1EA3n v\xE1 cho nginx (b\u1EA3n Ubuntu/Debian \u0111\xE3 h\u1EBFt h\u1ED7 tr\u1EE3), h\xE3y n\xE2ng c\u1EA5p OS,\n ho\u1EB7c chuy\u1EC3n sang kho ch\xEDnh th\u1EE9c nginx.org \u0111\u1EC3 c\xF3 b\u1EA3n m\u1EDBi nh\u1EA5t: https://nginx.org/en/linux_packages.html" | |
| ); | |
| } | |
| } | |
| } | |
| console.log(); | |
| if (commandExists("node")) { | |
| const v = execCapture("node", ["--version"]).stdout.trim(); | |
| const life = nodeLifecycle(v); | |
| if (life.eol) { | |
| danger( | |
| `Node.js ${v} \u0111\xE3 H\u1EBET H\u1EA0N H\u1ED6 TR\u1EE2${life.eolDate ? ` (EOL ${life.eolDate})` : " (b\u1EA3n l\u1EBB, kh\xF4ng ph\u1EA3i LTS)"} \u2014 s\u1EBD KH\xD4NG c\xF2n nh\u1EADn b\u1EA3n v\xE1 b\u1EA3o m\u1EADt n\xE0o n\u1EEFa, k\u1EC3 c\u1EA3 l\u1ED7i nghi\xEAm tr\u1ECDng.` | |
| ); | |
| info( | |
| "N\xE2ng c\u1EA5p l\xEAn LTS c\xF2n h\u1ED7 tr\u1EE3:\n curl -fsSL https://deb.nodesource.com/setup_24.x | sudo bash - && sudo apt-get install -y nodejs\n Sau \u0111\xF3 ch\u1EA1y l\u1EA1i 'napp app deploy <domain>' / 'napp service deploy <name>' \u0111\u1EC3 build l\u1EA1i native module." | |
| ); | |
| } else { | |
| ok(`Node.js ${v}${life.eolDate ? ` \u2014 c\xF2n h\u1ED7 tr\u1EE3 t\u1EDBi ${life.eolDate}` : ""}`); | |
| } | |
| } | |
| return report; | |
| } | |
| function allTargets() { | |
| const st = loadState(); | |
| const targets = []; | |
| for (const a of Object.values(st.apps)) { | |
| targets.push({ | |
| label: `app web ${a.domain}`, | |
| dir: a.webRoot, | |
| user: a.user, | |
| pm: a.packageManager ?? "npm" | |
| }); | |
| } | |
| for (const s of Object.values(st.services)) { | |
| targets.push({ | |
| label: `service ${s.name}`, | |
| dir: s.workDir, | |
| user: s.user, | |
| pm: s.packageManager ?? "npm" | |
| }); | |
| } | |
| return targets; | |
| } | |
| function resolveTargets(name) { | |
| const all = allTargets(); | |
| if (!name) return all; | |
| const found = all.filter((t) => t.label.endsWith(` ${name}`)); | |
| if (found.length === 0) { | |
| die( | |
| `Kh\xF4ng t\xECm th\u1EA5y app ho\u1EB7c service t\xEAn '${name}' trong registry. | |
| Xem danh s\xE1ch: napp app list / napp service list` | |
| ); | |
| } | |
| return found; | |
| } | |
| async function cmdDoctorDeps(opts) { | |
| section("Qu\xE9t r\u1EE7i ro chu\u1ED7i cung \u1EE9ng c\u1EE7a dependencies"); | |
| const targets = resolveTargets(opts.target); | |
| if (targets.length === 0) { | |
| info("Ch\u01B0a c\xF3 app/service n\xE0o \u0111\u01B0\u1EE3c napp qu\u1EA3n l\xFD \u2014 kh\xF4ng c\xF3 g\xEC \u0111\u1EC3 qu\xE9t."); | |
| return []; | |
| } | |
| let audit = opts.audit; | |
| if (audit && (process.getuid?.() ?? 0) !== 0) { | |
| warn("Ch\u01B0a ch\u1EA1y b\u1EB1ng sudo \u2014 B\u1ECE QUA ph\u1EA7n audit l\u1ED7 h\u1ED5ng (audit ph\u1EA3i ch\u1EA1y d\u01B0\u1EDBi user c\u1EE7a app). D\xF9ng 'sudo napp doctor deps' \u0111\u1EC3 qu\xE9t \u0111\u1EA7y \u0111\u1EE7."); | |
| audit = false; | |
| } | |
| const results = []; | |
| for (const t of targets) { | |
| console.log(); | |
| info(`${t.label} (${t.dir}, ${t.pm})`); | |
| const res = scanProject(t, { audit }); | |
| if (res.skipped) { | |
| step(`B\u1ECF qua: ${res.skipped}`); | |
| results.push(res); | |
| continue; | |
| } | |
| step(`\u0110\xE3 c\xE0i ${res.packageCount} package trong node_modules`); | |
| if (opts.deep) { | |
| const fresh = await checkFreshReleases(t); | |
| if (fresh.error) { | |
| step(`Kh\xF4ng tra \u0111\u01B0\u1EE3c tu\u1ED5i b\u1EA3n ph\xE1t h\xE0nh: ${fresh.error}`); | |
| } else if (fresh.fresh.length > 0) { | |
| res.findings.unshift({ | |
| severity: "medium", | |
| title: `${fresh.fresh.length} dependency v\u1EEBa c\xF3 b\u1EA3n ph\xE1t h\xE0nh r\u1EA5t m\u1EDBi (<= 14 ng\xE0y)`, | |
| detail: "G\xF3i b\u1ECB chi\u1EBFm t\xE0i kho\u1EA3n th\u01B0\u1EDDng ch\u1EC9 t\u1ED3n t\u1EA1i tr\xEAn registry v\xE0i gi\u1EDD t\u1EDBi v\xE0i ng\xE0y tr\u01B0\u1EDBc khi b\u1ECB g\u1EE1. B\u1EA3n ph\xE1t h\xE0nh c\xF2n qu\xE1 m\u1EDBi m\xE0 server \u0111\xE3 k\xE9o v\u1EC1 l\xE0 l\xFAc \u0111\xE1ng d\u1EEBng l\u1EA1i ki\u1EC3m tra:\n " + fresh.fresh.slice(0, 8).map((f) => `${f.name}@${f.installedVersion} \u2014 publish ${f.ageDays} ng\xE0y tr\u01B0\u1EDBc${f.isLatest ? " (\u0111ang l\xE0 latest)" : ""}`).join("\n "), | |
| fix: "\u0110\u1ED1i chi\u1EBFu changelog/commit c\u1EE7a b\u1EA3n m\u1EDBi tr\xEAn trang ch\xEDnh th\u1EE9c c\u1EE7a th\u01B0 vi\u1EC7n tr\u01B0\u1EDBc khi gi\u1EEF l\u1EA1i.\n N\u1EBFu kh\xF4ng r\xF5 ngu\u1ED3n g\u1ED1c: ghim t\u1EA1m v\u1EC1 b\u1EA3n c\u0169 \u0111\xE3 d\xF9ng \u1ED5n \u0111\u1ECBnh, commit lockfile, deploy l\u1EA1i." | |
| }); | |
| res.findings.sort((a, b) => SEVERITY_ORDER[a.severity] - SEVERITY_ORDER[b.severity]); | |
| } | |
| } | |
| if (res.findings.length === 0) { | |
| ok("Kh\xF4ng ph\xE1t hi\u1EC7n r\u1EE7i ro \u0111\xE1ng ch\xFA \xFD."); | |
| } else { | |
| for (const f of res.findings) { | |
| printSeverity(f.severity, f.title); | |
| step(f.detail); | |
| step(`C\xE1ch x\u1EED l\xFD: ${f.fix}`); | |
| } | |
| } | |
| results.push(res); | |
| } | |
| const counts = {}; | |
| for (const r of results) for (const f of r.findings) counts[f.severity] = (counts[f.severity] ?? 0) + 1; | |
| console.log(); | |
| const summary = ["critical", "high", "medium", "low", "info"].filter((s) => counts[s]).map((s) => `${SEVERITY_LABEL[s]}: ${counts[s]}`).join(" \xB7 "); | |
| if (!summary) ok(`\u0110\xE3 qu\xE9t ${results.length} d\u1EF1 \xE1n \u2014 kh\xF4ng c\xF3 ph\xE1t hi\u1EC7n n\xE0o.`); | |
| else { | |
| section("T\u1ED5ng k\u1EBFt dependencies"); | |
| console.log(` ${summary}`); | |
| if (!opts.deep) info("Th\xEAm '--deep' \u0111\u1EC3 tra th\xEAm tu\u1ED5i b\u1EA3n ph\xE1t h\xE0nh c\u1EE7a dependency tr\u1EF1c ti\u1EBFp tr\xEAn registry npm (c\u1EA7n m\u1EA1ng)."); | |
| } | |
| return results; | |
| } | |
| async function cmdDoctorUpgrade(opts) { | |
| requireRoot(); | |
| if (!hasApt()) die("Kh\xF4ng t\xECm th\u1EA5y apt-get \u2014 l\u1EC7nh n\xE0y ch\u1EC9 h\u1ED7 tr\u1EE3 Ubuntu/Debian."); | |
| section("C\xE0i b\u1EA3n v\xE1 cho h\u1EC7 th\u1ED1ng"); | |
| info("\u0110ang l\xE0m m\u1EDBi ch\u1EC9 m\u1EE5c g\xF3i (apt-get update)..."); | |
| runCmd("apt-get", ["update", "-qq"]); | |
| const pending = pendingUpdates(); | |
| if (pending.length === 0) { | |
| ok("H\u1EC7 th\u1ED1ng \u0111\xE3 \u1EDF b\u1EA3n m\u1EDBi nh\u1EA5t \u2014 kh\xF4ng c\xF3 g\xEC \u0111\u1EC3 c\xE0i."); | |
| return; | |
| } | |
| let selected; | |
| let scopeLabel; | |
| if (opts.only.length > 0) { | |
| selected = pending.filter((p) => opts.only.some((o) => p.pkg === o || p.pkg.startsWith(`${o}-`) || p.pkg.startsWith(`${o}.`))); | |
| scopeLabel = `c\xE1c g\xF3i \u0111\u01B0\u1EE3c ch\u1EC9 \u0111\u1ECBnh (${opts.only.join(", ")})`; | |
| if (selected.length === 0) { | |
| info(`Kh\xF4ng c\xF3 b\u1EA3n c\u1EADp nh\u1EADt n\xE0o \u0111ang ch\u1EDD cho: ${opts.only.join(", ")}. Kh\xF4ng c\u1EA7n l\xE0m g\xEC.`); | |
| return; | |
| } | |
| } else if (opts.all) { | |
| selected = pending; | |
| scopeLabel = "T\u1EA4T C\u1EA2 b\u1EA3n c\u1EADp nh\u1EADt \u0111ang ch\u1EDD"; | |
| } else { | |
| selected = pending.filter((p) => p.security); | |
| scopeLabel = "b\u1EA3n v\xE1 B\u1EA2O M\u1EACT"; | |
| if (selected.length === 0) { | |
| ok("Kh\xF4ng c\xF3 b\u1EA3n v\xE1 b\u1EA3o m\u1EADt n\xE0o \u0111ang ch\u1EDD."); | |
| if (pending.length > 0) info(`V\u1EABn c\xF2n ${pending.length} b\u1EA3n c\u1EADp nh\u1EADt th\u01B0\u1EDDng \u2014 c\xE0i b\u1EB1ng 'sudo napp doctor upgrade --all'.`); | |
| return; | |
| } | |
| } | |
| console.log(); | |
| info(`S\u1EBD c\xE0i ${selected.length} g\xF3i (${scopeLabel}):`); | |
| for (const p of selected.slice(0, 30)) step(`${p.pkg.padEnd(28)} ${p.currentVersion || "(m\u1EDBi)"} -> ${p.newVersion}`); | |
| if (selected.length > 30) step(`... v\xE0 ${selected.length - 30} g\xF3i n\u1EEFa`); | |
| console.log(); | |
| const proceed = await confirm2(`Ti\u1EBFn h\xE0nh c\xE0i ${selected.length} g\xF3i \u1EDF tr\xEAn?`, opts.yes); | |
| if (!proceed) { | |
| info("\u0110\xE3 hu\u1EF7. Kh\xF4ng thay \u0111\u1ED5i g\xEC."); | |
| return; | |
| } | |
| const aptArgs = [ | |
| "-y", | |
| "-o", | |
| "Dpkg::Options::=--force-confold", | |
| "-o", | |
| "Dpkg::Options::=--force-confdef", | |
| "install", | |
| "--only-upgrade", | |
| ...selected.map((p) => p.pkg) | |
| ]; | |
| runCmd("env", ["DEBIAN_FRONTEND=noninteractive", "apt-get", ...aptArgs]); | |
| ok(`\u0110\xE3 c\xE0i ${selected.length} g\xF3i.`); | |
| const touchedNginx = selected.some((p) => /^nginx/.test(p.pkg)); | |
| if (touchedNginx && commandExists("nginx")) { | |
| const t = execCapture("nginx", ["-t"]); | |
| if (t.code !== 0) { | |
| warn(`C\u1EA5u h\xECnh nginx KH\xD4NG h\u1EE3p l\u1EC7 sau khi n\xE2ng c\u1EA5p \u2014 KH\xD4NG restart \u0111\u1EC3 tr\xE1nh s\u1EADp site: | |
| ${t.stderr.trim()}`); | |
| die("H\xE3y s\u1EEDa c\u1EA5u h\xECnh r\u1ED3i ch\u1EA1y 'sudo nginx -t && sudo systemctl restart nginx'."); | |
| } | |
| ok("C\u1EA5u h\xECnh nginx h\u1EE3p l\u1EC7 (nginx -t)."); | |
| } | |
| const stale = managedUnits().map((unit) => ({ unit, libs: unitStaleLibraries(unit) ?? [] })).filter((s) => s.libs.length > 0); | |
| if (stale.length === 0) { | |
| ok("Kh\xF4ng d\u1ECBch v\u1EE5 n\xE0o c\xF2n n\u1EA1p th\u01B0 vi\u1EC7n c\u0169 \u2014 b\u1EA3n v\xE1 \u0111\xE3 c\xF3 hi\u1EC7u l\u1EF1c."); | |
| } else if (!opts.restart) { | |
| danger(`${stale.length} d\u1ECBch v\u1EE5 v\u1EABn ch\u1EA1y th\u01B0 vi\u1EC7n c\u0169 (b\u1EA3n v\xE1 CH\u01AFA c\xF3 hi\u1EC7u l\u1EF1c): ${stale.map((s) => s.unit).join(", ")}`); | |
| info(`Kh\u1EAFc ph\u1EE5c: sudo systemctl restart ${stale.map((s) => s.unit).join(" ")}`); | |
| } else { | |
| console.log(); | |
| info(`${stale.length} d\u1ECBch v\u1EE5 c\u1EA7n kh\u1EDFi \u0111\u1ED9ng l\u1EA1i \u0111\u1EC3 b\u1EA3n v\xE1 c\xF3 hi\u1EC7u l\u1EF1c: ${stale.map((s) => s.unit).join(", ")}`); | |
| const doRestart = await confirm2("Kh\u1EDFi \u0111\u1ED9ng l\u1EA1i c\xE1c d\u1ECBch v\u1EE5 n\xE0y ngay? (m\u1ED7i d\u1ECBch v\u1EE5 gi\xE1n \u0111o\u1EA1n d\u01B0\u1EDBi m\u1ED9t gi\xE2y)", opts.yes); | |
| if (!doRestart) { | |
| info(`B\u1ECF qua. Khi n\xE0o ti\u1EC7n: sudo systemctl restart ${stale.map((s) => s.unit).join(" ")}`); | |
| } else { | |
| for (const s of stale) { | |
| runCmd("systemctl", ["restart", s.unit], { silentFail: true }); | |
| ok(`\u0110\xE3 kh\u1EDFi \u0111\u1ED9ng l\u1EA1i ${s.unit}`); | |
| } | |
| } | |
| } | |
| const reboot = rebootRequired(); | |
| if (reboot.required) { | |
| console.log(); | |
| warn( | |
| "M\xE1y c\u1EA7n KH\u1EDEI \u0110\u1ED8NG L\u1EA0I \u0111\u1EC3 ho\xE0n t\u1EA5t b\u1EA3n v\xE1 (nh\xE2n/kernel ho\u1EB7c libc)" + (reboot.packages.length ? `: ${reboot.packages.slice(0, 6).join(", ")}` : "") + ". H\xE3y h\u1EB9n khung gi\u1EDD \xEDt truy c\u1EADp r\u1ED3i 'sudo reboot'." | |
| ); | |
| } | |
| } | |
| async function cmdDoctor(opts) { | |
| const report = cmdDoctorSystem({ refresh: opts.refresh }); | |
| const deps = await cmdDoctorDeps({ audit: opts.audit, deep: opts.deep }); | |
| section("T\xF3m t\u1EAFt"); | |
| const secCount = report?.security.length ?? 0; | |
| const depCritical = deps.reduce((n, r) => n + r.findings.filter((f) => f.severity === "critical" || f.severity === "high").length, 0); | |
| if (secCount === 0 && depCritical === 0) { | |
| ok("Kh\xF4ng ph\xE1t hi\u1EC7n r\u1EE7i ro b\u1EA3o m\u1EADt n\xE0o c\u1EA7n x\u1EED l\xFD ngay."); | |
| } else { | |
| if (secCount > 0) danger(`${secCount} g\xF3i h\u1EC7 th\u1ED1ng c\xF3 b\u1EA3n v\xE1 b\u1EA3o m\u1EADt \u0111ang ch\u1EDD -> sudo napp doctor upgrade`); | |
| if (depCritical > 0) danger(`${depCritical} r\u1EE7i ro m\u1EE9c CAO/NGHI\xCAM TR\u1ECCNG trong dependencies -> xem ph\u1EA7n h\u01B0\u1EDBng d\u1EABn x\u1EED l\xFD \u1EDF tr\xEAn`); | |
| } | |
| console.log(); | |
| info("N\xEAn ch\u1EA1y 'sudo napp doctor' \u0111\u1ECBnh k\u1EF3 (h\xE0ng tu\u1EA7n) \u2014 v\xE0 lu\xF4n 'napp update' \u0111\u1EC3 c\xF3 b\u1EA3ng CVE m\u1EDBi nh\u1EA5t."); | |
| } | |
| // src/commands/service.ts | |
| var import_node_fs19 = require("node:fs"); | |
| function resolveWritePaths(dirs, borrowedRoot) { | |
| const out = []; | |
| if (borrowedRoot) out.push(borrowedRoot); | |
| for (const raw of dirs) { | |
| const p = raw.trim().replace(/\/+$/, ""); | |
| if (!p.startsWith("/")) die(`--write-dir ph\u1EA3i l\xE0 \u0111\u01B0\u1EDDng d\u1EABn TUY\u1EC6T \u0110\u1ED0I, nh\u1EADn \u0111\u01B0\u1EE3c: '${raw}'`); | |
| if (/\s/.test(p)) die(`--write-dir kh\xF4ng \u0111\u01B0\u1EE3c ch\u1EE9a kho\u1EA3ng tr\u1EAFng (systemd t\xE1ch ReadWritePaths b\u1EB1ng d\u1EA5u c\xE1ch): '${raw}'`); | |
| if (!(0, import_node_fs19.existsSync)(p)) die(`--write-dir '${p}' kh\xF4ng t\u1ED3n t\u1EA1i. systemd s\u1EBD T\u1EEA CH\u1ED0I kh\u1EDFi \u0111\u1ED9ng unit n\u1EBFu ReadWritePaths tr\u1ECF v\xE0o ch\u1ED7 kh\xF4ng c\xF3 \u2014 h\xE3y t\u1EA1o th\u01B0 m\u1EE5c tr\u01B0\u1EDBc.`); | |
| out.push(p); | |
| } | |
| return [...new Set(out)]; | |
| } | |
| function serviceExists(name) { | |
| return getService(name) !== void 0; | |
| } | |
| function assertServiceAbsent(name, user, borrowedUser) { | |
| const conflicts = []; | |
| const workDir = serviceWorkDirFor(name); | |
| if ((0, import_node_fs19.existsSync)(workDir)) conflicts.push(`th\u01B0 m\u1EE5c m\xE3 ngu\u1ED3n: ${workDir}`); | |
| const clash = Object.values(loadState().apps).find((a) => a.webRoot === workDir); | |
| if (clash) conflicts.push(`app web '${clash.domain}' \u0111ang d\xF9ng th\u01B0 m\u1EE5c n\xE0y`); | |
| const unit = `${SYSTEMD_DIR}/${svcSystemdName(name)}.service`; | |
| if ((0, import_node_fs19.existsSync)(unit)) conflicts.push(`systemd unit: ${unit}`); | |
| if (!borrowedUser && execCapture("id", [user]).code === 0) conflicts.push(`user h\u1EC7 th\u1ED1ng: ${user}`); | |
| if (serviceExists(name)) conflicts.push(`registry: \u0111\xE3 c\xF3 service '${name}' trong /etc/napp/state.json`); | |
| if (conflicts.length > 0) { | |
| die( | |
| `Background service '${name}' (ho\u1EB7c t\xE0i nguy\xEAn c\xF9ng t\xEAn) \u0110\xC3 T\u1ED2N T\u1EA0I \u2014 kh\xF4ng t\u1EA1o tr\xF9ng. | |
| ` + conflicts.map((c) => ` - ${c}`).join("\n") + ` | |
| Mu\u1ED1n t\u1EA1o l\u1EA1i? H\xE3y xo\xE1 tr\u01B0\u1EDBc b\u1EB1ng: napp service remove ${name}` | |
| ); | |
| } | |
| } | |
| async function cmdServiceCreate(name, opts) { | |
| requireRoot(); | |
| validateServiceName(name); | |
| if (name.endsWith(SERVICE_DIR_SUFFIX)) { | |
| die( | |
| `T\xEAn service kh\xF4ng \u0111\u01B0\u1EE3c k\u1EBFt th\xFAc b\u1EB1ng '${SERVICE_DIR_SUFFIX}' \u2014 napp t\u1EF1 th\xEAm h\u1EADu t\u1ED1 n\xE0y v\xE0o t\xEAn th\u01B0 m\u1EE5c. | |
| H\xE3y d\xF9ng: napp service create ${name.slice(0, -SERVICE_DIR_SUFFIX.length)} ... (m\xE3 ngu\u1ED3n s\u1EBD \u1EDF ${serviceWorkDirFor(name.slice(0, -SERVICE_DIR_SUFFIX.length))})` | |
| ); | |
| } | |
| validateBranch(opts.branch); | |
| if (opts.repo) validateRepoUrl(opts.repo); | |
| prepareRepoAuth(opts); | |
| const borrowed = opts.runAs ? findUnit(opts.runAs) : void 0; | |
| if (opts.runAs && !borrowed) { | |
| die( | |
| `--run-as: kh\xF4ng t\xECm th\u1EA5y app/service '${opts.runAs}' trong registry (/etc/napp/state.json). | |
| Xem danh s\xE1ch: napp app list \xB7 napp service list` | |
| ); | |
| } | |
| if (borrowed && execCapture("id", [borrowed.user]).code !== 0) { | |
| die( | |
| `--run-as '${borrowed.id}': user h\u1EC7 th\u1ED1ng '${borrowed.user}' kh\xF4ng c\xF2n t\u1ED3n t\u1EA1i tr\xEAn m\xE1y (registry v\xE0 h\u1EC7 th\u1ED1ng l\u1EC7ch nhau). | |
| H\xE3y t\u1EA1o l\u1EA1i \u0111\u01A1n v\u1ECB \u0111\xF3, ho\u1EB7c b\u1ECF --run-as \u0111\u1EC3 service n\xE0y c\xF3 user ri\xEAng.` | |
| ); | |
| } | |
| const user = borrowed ? borrowed.user : serviceUserFor(name); | |
| const workDir = serviceWorkDirFor(name); | |
| const writePaths = resolveWritePaths(opts.writeDirs, borrowed?.root); | |
| const port = opts.port !== void 0 ? allocatePort(opts.port) : void 0; | |
| if (port !== void 0) validatePort(port); | |
| const unitName = svcSystemdName(name); | |
| assertServiceAbsent(name, user, borrowed !== void 0); | |
| const pm = opts.packageManager ?? defaultPackageManager(opts.runtime); | |
| ensureRuntime(opts.runtime); | |
| ensurePackageManager(pm); | |
| const release = acquireLock(name); | |
| let rollbackActive = true; | |
| let dbCreatedName; | |
| const rollback = () => { | |
| if (!rollbackActive) return; | |
| warn("T\u1EA1o service th\u1EA5t b\u1EA1i \u2014 \u0111ang ho\xE0n t\xE1c c\xE1c thay \u0111\u1ED5i \u0111\xE3 th\u1EF1c hi\u1EC7n..."); | |
| try { | |
| runCmd("systemctl", ["stop", unitName], { silentFail: true }); | |
| runCmd("systemctl", ["disable", unitName], { silentFail: true }); | |
| runCmd("rm", ["-f", `${SYSTEMD_DIR}/${unitName}.service`], { silentFail: true }); | |
| runCmd("systemctl", ["daemon-reload"], { silentFail: true }); | |
| if ((0, import_node_fs19.existsSync)(workDir)) (0, import_node_fs19.rmSync)(workDir, { recursive: true, force: true }); | |
| if (!borrowed && execCapture("id", [user]).code === 0) { | |
| runCmd("userdel", ["-r", user], { silentFail: true }); | |
| } | |
| if (dbCreatedName) { | |
| try { | |
| dropDatabase(dbCreatedName, dbCreatedName); | |
| } catch { | |
| } | |
| } | |
| warn(`\u0110\xE3 ho\xE0n t\xE1c. H\u1EC7 th\u1ED1ng tr\u1EDF l\u1EA1i tr\u1EA1ng th\xE1i tr\u01B0\u1EDBc khi t\u1EA1o service '${name}'.`); | |
| } finally { | |
| release(); | |
| } | |
| }; | |
| try { | |
| section(`T\u1EA1o background service ${name}`); | |
| info(`Runtime ${opts.runtime}, qu\u1EA3n l\xFD g\xF3i ${pm}, user h\u1EC7 th\u1ED1ng ${user}${port !== void 0 ? `, c\u1ED5ng ${port}` : " (kh\xF4ng c\u1ED5ng)"}`); | |
| if (borrowed) { | |
| ok(`Ch\u1EA1y b\u1EB1ng user '${user}' c\u1EE7a ${borrowed.kind === "app" ? "app web" : "service"} '${borrowed.id}' \u2014 kh\xF4ng t\u1EA1o user m\u1EDBi.`); | |
| warn( | |
| `--run-as \u0110\xC1NH \u0110\u1ED4I S\u1EF0 C\xD4 L\u1EACP \u0111\u1EC3 l\u1EA5y quy\u1EC1n ghi file: | |
| - Worker n\xE0y v\xE0 '${borrowed.id}' l\xE0 C\xD9NG M\u1ED8T danh t\xEDnh Unix. Worker \u0111\u1ECDc/ghi \u0111\u01B0\u1EE3c | |
| m\u1ECDi th\u1EE9 c\u1EE7a '${borrowed.id}', k\u1EC3 c\u1EA3 .env (m\u1EADt kh\u1EA9u DB, kho\xE1 API) \u2014 v\xE0 ng\u01B0\u1EE3c l\u1EA1i. | |
| - M\u1ED9t b\xEAn b\u1ECB chi\u1EBFm quy\u1EC1n l\xE0 b\xEAn kia m\u1EA5t theo. Ch\u1EC9 d\xF9ng khi hai b\xEAn l\xE0 hai n\u1EEDa c\u1EE7a | |
| C\xD9NG m\u1ED9t s\u1EA3n ph\u1EA9m; hai s\u1EA3n ph\u1EA9m kh\xE1c nhau th\xEC \u0111\u1EEBng d\xF9ng. | |
| - G\u1EE1 service n\xE0y v\u1EC1 sau s\u1EBD KH\xD4NG xo\xE1 user (user thu\u1ED9c v\u1EC1 '${borrowed.id}').` | |
| ); | |
| } else { | |
| runCmd("useradd", ["--system", "--create-home", "--home-dir", `/home/${user}`, "--shell", "/usr/sbin/nologin", user]); | |
| ok(`\u0110\xE3 t\u1EA1o user h\u1EC7 th\u1ED1ng ${user}`); | |
| } | |
| ensureDir(workDir); | |
| runCmd("chown", [`${user}:${user}`, workDir]); | |
| if (opts.repo) { | |
| if (opts.token || opts.sshKey) setupRepoAuth(user, opts.repo, opts); | |
| info(`\u0110ang clone ${opts.repo} (branch ${opts.branch})...`); | |
| const clone = runAs(user, "git", ["clone", "--branch", opts.branch, "--depth", "1", opts.repo, workDir], { | |
| env: GIT_NONINTERACTIVE_ENV, | |
| silentFail: true | |
| }); | |
| if (clone.code !== 0) { | |
| const privateHint = !opts.token && !opts.sshKey ? ` | |
| N\u1EBFu \u0111\xE2y l\xE0 repo PRIVATE: napp KH\xD4NG h\u1ECFi m\u1EADt kh\u1EA9u t\u01B0\u01A1ng t\xE1c (tr\xE1nh treo). H\xE3y th\xEAm: | |
| - Repo HTTPS: --token <Personal-Access-Token> | |
| - Repo SSH : --ssh-key <\u0111\u01B0\u1EDDng-d\u1EABn-deploy-key>` : ` | |
| Ki\u1EC3m tra l\u1EA1i token/deploy key c\xF3 quy\u1EC1n \u0111\u1ECDc repo, v\xE0 branch '${opts.branch}' t\u1ED3n t\u1EA1i.`; | |
| die(`Clone repo th\u1EA5t b\u1EA1i (m\xE3 ${clone.code}). Ki\u1EC3m tra URL/branch, m\u1EA1ng, ho\u1EB7c quy\u1EC1n truy c\u1EADp.${privateHint}`); | |
| } | |
| } else { | |
| info("Kh\xF4ng c\xF3 --repo \u2014 t\u1EA1o worker m\u1EABu t\u1ED1i gi\u1EA3n \u0111\u1EC3 b\u1EA1n t\u1EF1 \u0111\u01B0a m\xE3 ngu\u1ED3n l\xEAn sau..."); | |
| runAs(user, "bash", [ | |
| "-lc", | |
| `cat > ${JSON.stringify(workDir + "/package.json")} <<'EOF' | |
| { | |
| "name": "${name.replace(/[^a-z0-9-]/gi, "-")}", | |
| "version": "1.0.0", | |
| "private": true, | |
| "scripts": { "start": "node worker.js" } | |
| } | |
| EOF | |
| cat > ${JSON.stringify(workDir + "/worker.js")} <<'EOF' | |
| // File t\u1EA1m do napp t\u1EA1o \u2014 h\xE3y thay b\u1EB1ng m\xE3 ngu\u1ED3n th\u1EADt c\u1EE7a background service. | |
| // \u0110\xE2y l\xE0 m\u1ED9t ti\u1EBFn tr\xECnh ch\u1EA1y NG\u1EA6M (kh\xF4ng HTTP, kh\xF4ng domain). systemd s\u1EBD t\u1EF1 | |
| // kh\u1EDFi \u0111\u1ED9ng l\u1EA1i n\u1EBFu ti\u1EBFn tr\xECnh tho\xE1t. In heartbeat \u0111\u1EC3 'napp service logs' th\u1EA5y. | |
| const started = new Date().toISOString(); | |
| console.log("[napp] background service '${name}' \u0111\xE3 kh\u1EDFi \u0111\u1ED9ng l\xFAc " + started); | |
| setInterval(() => { | |
| console.log("[napp] heartbeat " + new Date().toISOString()); | |
| }, 60000); | |
| // Gi\u1EEF ti\u1EBFn tr\xECnh s\u1ED1ng; thay b\u1EB1ng v\xF2ng l\u1EB7p x\u1EED l\xFD c\xF4ng vi\u1EC7c th\u1EADt c\u1EE7a b\u1EA1n. | |
| process.on("SIGTERM", () => { console.log("[napp] nh\u1EADn SIGTERM \u2014 tho\xE1t."); process.exit(0); }); | |
| EOF` | |
| ]); | |
| } | |
| const installCmd = opts.installCmd ?? defaultInstallCmd(pm); | |
| const buildCmd = opts.buildCmd ?? ""; | |
| const startCmd = opts.startCmd ?? defaultStartCmd(opts.runtime, pm); | |
| if ((0, import_node_fs19.existsSync)(`${workDir}/package.json`) || opts.repo) { | |
| info("\u0110ang c\xE0i dependencies..."); | |
| runAs(user, "bash", ["-lc", installCmd], { cwd: workDir }); | |
| if (buildCmd) { | |
| info("\u0110ang build..."); | |
| runAs(user, "bash", ["-lc", buildCmd], { cwd: workDir }); | |
| } | |
| } | |
| let dbInfo; | |
| if (opts.db) { | |
| dbInfo = createDatabase(user, user); | |
| dbCreatedName = dbInfo.name; | |
| ok(`\u0110\xE3 t\u1EA1o database '${dbInfo.name}' + user CSDL '${dbInfo.user}'@'localhost'`); | |
| } | |
| let redisDbIndex; | |
| if (opts.redis || opts.redisDb !== void 0 || opts.shareRedisWith) { | |
| const preferred = opts.shareRedisWith ? redisDbOf(opts.shareRedisWith) : opts.redisDb; | |
| redisDbIndex = resolveRedisDb(preferred); | |
| if (redisDbIndex === void 0) { | |
| warn("\u0110\xE3 h\u1EBFt database Redis ri\xEAng (0-15). B\u1ECF qua c\u1EA5p DB ri\xEAng \u2014 h\xE3y d\xF9ng key-prefix trong service thay v\xEC DB ri\xEAng."); | |
| } else if (preferred !== void 0) { | |
| ok(`D\xF9ng CHUNG Redis DB #${redisDbIndex}${opts.shareRedisWith ? ` v\u1EDBi '${opts.shareRedisWith}'` : ""}`); | |
| } else { | |
| ok(`\u0110\xE3 c\u1EA5p Redis DB #${redisDbIndex} cho service n\xE0y`); | |
| warn( | |
| `Service n\xE0y d\xF9ng Redis DB RI\xCANG (#${redisDbIndex}). | |
| N\u1EBFu n\xF3 l\xE0 worker x\u1EED l\xFD h\xE0ng \u0111\u1EE3i c\u1EE7a m\u1ED9t web app, hai b\xEAn PH\u1EA2I d\xF9ng chung DB \u2014 | |
| kh\xE1c DB th\xEC job \u0111\u01B0\u1EE3c \u0111\u1EA9y v\xE0o m\u1ED9t n\u01A1i c\xF2n worker nghe \u1EDF n\u01A1i kh\xE1c, KH\xD4NG B\xCAN N\xC0O B\xC1O L\u1ED6I. | |
| T\u1EA1o l\u1EA1i v\u1EDBi: --share-redis-with <domain-cua-web-app>` | |
| ); | |
| } | |
| } | |
| const envUpdates = { NODE_ENV: "production" }; | |
| if (port !== void 0) envUpdates.PORT = String(port); | |
| if (dbInfo) { | |
| envUpdates.DB_CONNECTION = "mysql"; | |
| envUpdates.DB_HOST = "127.0.0.1"; | |
| envUpdates.DB_PORT = "3306"; | |
| envUpdates.DB_DATABASE = dbInfo.name; | |
| envUpdates.DB_USERNAME = dbInfo.user; | |
| envUpdates.DB_PASSWORD = dbInfo.password; | |
| } | |
| if (redisDbIndex !== void 0) { | |
| envUpdates.REDIS_HOST = "127.0.0.1"; | |
| envUpdates.REDIS_PORT = "6379"; | |
| envUpdates.REDIS_DB = String(redisDbIndex); | |
| envUpdates.REDIS_URL = `redis://127.0.0.1:6379/${redisDbIndex}`; | |
| } | |
| for (const kv of opts.env) { | |
| const eq = kv.indexOf("="); | |
| if (eq === -1) die(`--env ph\u1EA3i theo d\u1EA1ng KEY=VALUE, nh\u1EADn \u0111\u01B0\u1EE3c: '${kv}'`); | |
| const key = kv.slice(0, eq); | |
| validateEnvKey(key); | |
| envUpdates[key] = kv.slice(eq + 1); | |
| } | |
| const svcWorkDir = unitWorkDir(workDir, opts.appDir); | |
| if (svcWorkDir !== workDir) ensureDir(svcWorkDir); | |
| const envPath = `${svcWorkDir}/.env`; | |
| mergeEnvFile(envPath, envUpdates, 384); | |
| runCmd("chown", [`${user}:${user}`, envPath]); | |
| ok("\u0110\xE3 ghi c\u1EA5u h\xECnh v\xE0o .env (quy\u1EC1n 600, ch\u1EC9 user c\u1EE7a service \u0111\u1ECDc \u0111\u01B0\u1EE3c)"); | |
| runCmd("chown", ["-R", `${user}:${user}`, workDir]); | |
| runCmd("find", [workDir, "-type", "d", "-exec", "chmod", "750", "{}", "+"]); | |
| runCmd("find", [workDir, "-type", "f", "-exec", "chmod", "640", "{}", "+"]); | |
| runCmd("chmod", ["600", envPath]); | |
| ensureDir("/var/log/napp", 488); | |
| const record = { | |
| name, | |
| user, | |
| workDir, | |
| nodeRuntime: opts.runtime, | |
| packageManager: pm, | |
| installCmd, | |
| buildCmd, | |
| startCmd, | |
| port, | |
| repoUrl: opts.repo, | |
| branch: opts.branch, | |
| dbName: dbInfo?.name, | |
| dbUser: dbInfo?.user, | |
| redisDbIndex, | |
| appDir: opts.appDir, | |
| runAsUnit: borrowed?.id, | |
| writePaths: writePaths.length > 0 ? writePaths : void 0, | |
| createdAt: (/* @__PURE__ */ new Date()).toISOString(), | |
| updatedAt: (/* @__PURE__ */ new Date()).toISOString() | |
| }; | |
| const plan = currentHeapPlan({ services: 1 }); | |
| const heapMB = plan.serviceMB; | |
| writeServiceUnit(record, heapMB); | |
| if (opts.runtime === "node") { | |
| info( | |
| `NODE_OPTIONS=--max-old-space-size=${heapMB} (background service nh\u1EADn ph\u1EA7n nh\u1ECF h\u01A1n web app: ${plan.serviceMB} MB so v\u1EDBi ${plan.webMB} MB; \u0111\u1ED5i trong .env n\u1EBFu c\u1EA7n)` | |
| ); | |
| } | |
| info( | |
| `\u01AFu ti\xEAn t\xE0i nguy\xEAn: CPUWeight/IOWeight th\u1EA5p h\u01A1n web app + MemoryHigh ${serviceMemoryHighMB(heapMB)} MB (gi\u1EDBi h\u1EA1n M\u1EC0M) \u2014 worker n\xE9n \u1EA3nh/video s\u1EBD kh\xF4ng l\xE0m ch\u1EADm request c\u1EE7a ng\u01B0\u1EDDi d\xF9ng th\u1EADt khi tranh ch\u1EA5p CPU.` | |
| ); | |
| runCmd("systemctl", ["daemon-reload"]); | |
| runCmd("systemctl", ["enable", unitName]); | |
| runCmd("systemctl", ["restart", unitName]); | |
| ok(`\u0110\xE3 t\u1EA1o v\xE0 kh\u1EDFi \u0111\u1ED9ng systemd service '${unitName}'`); | |
| upsertService(record); | |
| rollbackActive = false; | |
| release(); | |
| const mixNow = unitMix(); | |
| if (mixNow.webApps + mixNow.services > 1) reportBalance(applyNodeHeaps({ restart: true, skipRestartFor: name })); | |
| console.log(); | |
| console.log("==============================================================="); | |
| ok("T\u1EA1o background service th\xE0nh c\xF4ng!"); | |
| console.log(` T\xEAn service : ${name}`); | |
| console.log(` M\xE3 ngu\u1ED3n : ${workDir}`); | |
| console.log(` Ch\u1EA1y b\u1EB1ng : ${user}${borrowed ? ` \u2014 user c\u1EE7a '${borrowed.id}' (d\xF9ng chung danh t\xEDnh)` : " (user ri\xEAng)"} (systemd: ${unitName})`); | |
| if (writePaths.length > 0) console.log(` Ghi \u0111\u01B0\u1EE3c v\xE0o : ${workDir} \xB7 ${writePaths.join(" \xB7 ")}`); | |
| console.log(` Runtime : ${opts.runtime} \xB7 qu\u1EA3n l\xFD g\xF3i: ${pm}`); | |
| if (port !== void 0) console.log(` C\u1ED5ng n\u1ED9i b\u1ED9 : 127.0.0.1:${port} (service t\u1EF1 bind \u2014 KH\xD4NG public qua nginx)`); | |
| else console.log(` C\u1ED5ng : kh\xF4ng c\u1EA5p (worker ch\u1EA1y ng\u1EA7m, kh\xF4ng listen)`); | |
| if (dbInfo) console.log(` Database : ${dbInfo.name} (user: ${dbInfo.user}@localhost, m\u1EADt kh\u1EA9u trong .env)`); | |
| if (redisDbIndex !== void 0) console.log(` Redis DB : #${redisDbIndex}`); | |
| console.log(); | |
| console.log(" C\xE1c b\u01B0\u1EDBc ti\u1EBFp theo:"); | |
| console.log(` 1. Xem log: sudo napp service logs ${name} -f`); | |
| console.log(` 2. Deploy b\u1EA3n m\u1EDBi (n\u1EBFu c\xF3 --repo): sudo napp service deploy ${name}`); | |
| console.log("==============================================================="); | |
| } catch (e) { | |
| rollback(); | |
| throw e; | |
| } | |
| } | |
| async function cmdServiceDeploy(name) { | |
| requireRoot(); | |
| validateServiceName(name); | |
| const svc = requireService(name); | |
| if (!svc.repoUrl) die(`Service '${name}' kh\xF4ng c\xF3 --repo li\xEAn k\u1EBFt \u2014 kh\xF4ng c\xF3 g\xEC \u0111\u1EC3 deploy. H\xE3y t\u1EF1 c\u1EADp nh\u1EADt m\xE3 ngu\u1ED3n th\u1EE7 c\xF4ng r\u1ED3i 'napp service restart ${name}'.`); | |
| const release = acquireLock(name); | |
| try { | |
| section(`Deploy service ${name}`); | |
| info(`\u0110ang git pull (${svc.branch})...`); | |
| runAs(svc.user, "git", ["fetch", "origin", svc.branch], { cwd: svc.workDir, env: GIT_NONINTERACTIVE_ENV }); | |
| runAs(svc.user, "git", ["reset", "--hard", `origin/${svc.branch}`], { cwd: svc.workDir, env: GIT_NONINTERACTIVE_ENV }); | |
| if (svc.packageManager) ensurePackageManager(svc.packageManager); | |
| info("\u0110ang c\xE0i dependencies..."); | |
| runAs(svc.user, "bash", ["-lc", svc.installCmd], { cwd: svc.workDir }); | |
| if (svc.buildCmd) { | |
| info("\u0110ang build..."); | |
| runAs(svc.user, "bash", ["-lc", svc.buildCmd], { cwd: svc.workDir }); | |
| } | |
| runCmd("chown", ["-R", `${svc.user}:${svc.user}`, svc.workDir]); | |
| runCmd("chmod", ["600", `${unitWorkDir(svc.workDir, svc.appDir)}/.env`], { silentFail: true }); | |
| runCmd("systemctl", ["restart", svcSystemdName(name)]); | |
| svc.updatedAt = (/* @__PURE__ */ new Date()).toISOString(); | |
| upsertService(svc); | |
| ok(`Deploy ho\xE0n t\u1EA5t \u2014 \u0111\xE3 kh\u1EDFi \u0111\u1ED9ng l\u1EA1i ${svcSystemdName(name)}`); | |
| } finally { | |
| release(); | |
| } | |
| } | |
| async function cmdServiceSet(name, opts) { | |
| requireRoot(); | |
| validateServiceName(name); | |
| const svc = requireService(name); | |
| if (opts.runAs && opts.standalone) die("--run-as v\xE0 --standalone lo\u1EA1i tr\u1EEB nhau: ho\u1EB7c m\u01B0\u1EE3n user c\u1EE7a \u0111\u01A1n v\u1ECB kh\xE1c, ho\u1EB7c d\xF9ng user ri\xEAng."); | |
| if (!opts.runAs && !opts.standalone && opts.writeDirs.length === 0 && !opts.clearWriteDirs) { | |
| die( | |
| `Kh\xF4ng c\xF3 g\xEC \u0111\u1EC3 \u0111\u1ED5i. C\xE1c tu\u1EF3 ch\u1ECDn: | |
| --run-as <domain|name> ch\u1EA1y b\u1EB1ng user c\u1EE7a app/service \u0111\xE3 c\xF3 (worker ghi \u0111\u01B0\u1EE3c v\xE0o th\u01B0 m\u1EE5c c\u1EE7a n\xF3) | |
| --standalone quay v\u1EC1 user ri\xEAng '${serviceUserFor(name)}' (c\xF4 l\u1EADp ho\xE0n to\xE0n) | |
| --write-dir <path> \u0111\u1EB7t l\u1EA1i danh s\xE1ch \u0111\u01B0\u1EDDng d\u1EABn \u0111\u01B0\u1EE3c ghi th\xEAm (l\u1EB7p l\u1EA1i \u0111\u01B0\u1EE3c) | |
| --no-write-dir b\u1ECF h\u1EBFt \u0111\u01B0\u1EDDng d\u1EABn ghi th\xEAm` | |
| ); | |
| } | |
| const release = acquireLock(name); | |
| try { | |
| section(`\u0110\u1ED5i c\u1EA5u h\xECnh ch\u1EA1y c\u1EE7a service ${name}`); | |
| const oldUser = svc.user; | |
| let borrowed = svc.runAsUnit ? findUnit(svc.runAsUnit) : void 0; | |
| const previousRoot = borrowed?.root; | |
| if (opts.runAs) { | |
| const target = findUnit(opts.runAs); | |
| if (!target) die(`--run-as: kh\xF4ng t\xECm th\u1EA5y app/service '${opts.runAs}' trong registry. Xem: napp app list \xB7 napp service list`); | |
| if (execCapture("id", [target.user]).code !== 0) die(`--run-as '${target.id}': user h\u1EC7 th\u1ED1ng '${target.user}' kh\xF4ng t\u1ED3n t\u1EA1i tr\xEAn m\xE1y.`); | |
| borrowed = target; | |
| svc.user = target.user; | |
| svc.runAsUnit = target.id; | |
| } else if (opts.standalone) { | |
| const own = serviceUserFor(name); | |
| if (execCapture("id", [own]).code !== 0) { | |
| runCmd("useradd", ["--system", "--create-home", "--home-dir", `/home/${own}`, "--shell", "/usr/sbin/nologin", own]); | |
| ok(`\u0110\xE3 t\u1EA1o user h\u1EC7 th\u1ED1ng ri\xEAng '${own}'.`); | |
| } | |
| borrowed = void 0; | |
| svc.user = own; | |
| svc.runAsUnit = void 0; | |
| } | |
| const keepDirs = opts.clearWriteDirs ? [] : opts.writeDirs.length > 0 ? opts.writeDirs : (svc.writePaths ?? []).filter((p) => p !== borrowed?.root && p !== previousRoot); | |
| const writePaths = resolveWritePaths(keepDirs, borrowed?.root); | |
| svc.writePaths = writePaths.length > 0 ? writePaths : void 0; | |
| if (svc.user !== oldUser) { | |
| runCmd("chown", ["-R", `${svc.user}:${svc.user}`, svc.workDir]); | |
| runCmd("chmod", ["600", `${unitWorkDir(svc.workDir, svc.appDir)}/.env`], { silentFail: true }); | |
| ok(`M\xE3 ngu\u1ED3n ${svc.workDir} \u0111\xE3 chuy\u1EC3n sang user '${svc.user}'.`); | |
| } | |
| svc.updatedAt = (/* @__PURE__ */ new Date()).toISOString(); | |
| upsertService(svc); | |
| writeServiceUnit(svc, currentHeapPlan().serviceMB, ["User", "Group"]); | |
| runCmd("systemctl", ["daemon-reload"]); | |
| runCmd("systemctl", ["restart", svcSystemdName(name)]); | |
| if (svc.runAsUnit) { | |
| ok(`Service '${name}' nay ch\u1EA1y b\u1EB1ng user '${svc.user}' c\u1EE7a '${svc.runAsUnit}'.`); | |
| warn( | |
| `Hai b\xEAn nay l\xE0 C\xD9NG M\u1ED8T danh t\xEDnh Unix: worker \u0111\u1ECDc/ghi \u0111\u01B0\u1EE3c m\u1ECDi th\u1EE9 c\u1EE7a '${svc.runAsUnit}' (k\u1EC3 c\u1EA3 .env) v\xE0 ng\u01B0\u1EE3c l\u1EA1i. Ch\u1EC9 n\xEAn d\xF9ng khi ch\xFAng l\xE0 hai n\u1EEDa c\u1EE7a c\xF9ng m\u1ED9t s\u1EA3n ph\u1EA9m.` | |
| ); | |
| } else { | |
| ok(`Service '${name}' nay ch\u1EA1y b\u1EB1ng user ri\xEAng '${svc.user}'.`); | |
| } | |
| if (writePaths.length > 0) info(`Ghi \u0111\u01B0\u1EE3c v\xE0o: ${svc.workDir} \xB7 ${writePaths.join(" \xB7 ")}`); | |
| else info(`Ghi \u0111\u01B0\u1EE3c v\xE0o: ${svc.workDir} (ch\u1EC9 m\xE3 ngu\u1ED3n c\u1EE7a ch\xEDnh n\xF3)`); | |
| if (svc.user !== oldUser && oldUser.startsWith(SERVICE_USER_PREFIX) && servicesRunningAs(name, oldUser).length === 0) { | |
| const stillUsed = Object.values(loadState().services).some((s) => s.user === oldUser); | |
| if (!stillUsed) info(`User c\u0169 '${oldUser}' kh\xF4ng c\xF2n \u0111\u01A1n v\u1ECB n\xE0o d\xF9ng. Mu\u1ED1n d\u1ECDn: sudo userdel -r ${oldUser}`); | |
| } | |
| } finally { | |
| release(); | |
| } | |
| } | |
| async function cmdServiceRemove(name, opts) { | |
| requireRoot(); | |
| validateServiceName(name); | |
| const svc = requireService(name); | |
| if (opts.source && !opts.database && svc.dbName) { | |
| warn( | |
| `B\u1EA1n ch\u1ECDn xo\xE1 m\xE3 ngu\u1ED3n nh\u01B0ng gi\u1EEF database '${svc.dbName}' \u2014 m\u1EADt kh\u1EA9u DB ch\u1EC9 l\u01B0u trong .env (n\u1EB1m trong m\xE3 ngu\u1ED3n), xo\xE1 \u0111i l\xE0 M\u1EA4T. Database v\xE0 d\u1EEF li\u1EC7u v\u1EABn c\xF2n, nh\u01B0ng mu\u1ED1n d\xF9ng l\u1EA1i ph\u1EA3i \u0111\u1EB7t m\u1EADt kh\u1EA9u m\u1EDBi. H\xE3y sao ch\xE9p .env ra n\u01A1i kh\xE1c tr\u01B0\u1EDBc n\u1EBFu c\u1EA7n.` | |
| ); | |
| } | |
| const willDelete = [ | |
| `service systemd (${svcSystemdName(name)})`, | |
| ...opts.source ? [svc.runAsUnit ? `m\xE3 ngu\u1ED3n (${svc.workDir}) \u2014 GI\u1EEE user '${svc.user}' v\xEC n\xF3 thu\u1ED9c v\u1EC1 '${svc.runAsUnit}'` : `m\xE3 ngu\u1ED3n (${svc.workDir}) + user h\u1EC7 th\u1ED1ng '${svc.user}'`] : [], | |
| ...opts.database && svc.dbName ? [`database '${svc.dbName}'`] : [] | |
| ]; | |
| const willKeep = [ | |
| ...!opts.source ? [`m\xE3 ngu\u1ED3n (${svc.workDir})`] : [], | |
| ...!opts.database && svc.dbName ? [`database '${svc.dbName}'`] : [] | |
| ]; | |
| if (!opts.yes) { | |
| const readline2 = await import("node:readline/promises"); | |
| const rl2 = readline2.createInterface({ input: process.stdin, output: process.stdout }); | |
| const ans = await rl2.question( | |
| `Thao t\xE1c n\xE0y s\u1EBD g\u1EE1 service '${name}' kh\u1ECFi napp v\xE0 XO\xC1: | |
| ` + willDelete.map((w) => ` - ${w}`).join("\n") + (willKeep.length ? ` | |
| GI\u1EEE l\u1EA1i: | |
| ` + willKeep.map((w) => ` - ${w}`).join("\n") : "") + ` | |
| Ti\u1EBFp t\u1EE5c? [y/N] ` | |
| ); | |
| rl2.close(); | |
| if (!/^y(es)?$/i.test(ans.trim())) { | |
| info("\u0110\xE3 hu\u1EF7."); | |
| return; | |
| } | |
| } | |
| const release = acquireLock(name); | |
| try { | |
| const unitName = svcSystemdName(name); | |
| runCmd("systemctl", ["stop", unitName], { silentFail: true }); | |
| runCmd("systemctl", ["disable", unitName], { silentFail: true }); | |
| runCmd("rm", ["-f", `${SYSTEMD_DIR}/${unitName}.service`], { silentFail: true }); | |
| runCmd("systemctl", ["daemon-reload"], { silentFail: true }); | |
| if (opts.source) { | |
| if ((0, import_node_fs19.existsSync)(svc.workDir)) { | |
| try { | |
| (0, import_node_fs19.rmSync)(svc.workDir, { recursive: true, force: true }); | |
| ok(`\u0110\xE3 xo\xE1 m\xE3 ngu\u1ED3n ${svc.workDir}.`); | |
| } catch (e) { | |
| warn(`Kh\xF4ng xo\xE1 \u0111\u01B0\u1EE3c th\u01B0 m\u1EE5c m\xE3 ngu\u1ED3n ${svc.workDir} (${e.message}) \u2014 h\xE3y t\u1EF1 xo\xE1 sau.`); | |
| } | |
| } | |
| const borrowers = servicesRunningAs(name, svc.user).filter((s) => s.name !== name); | |
| if (svc.runAsUnit) { | |
| info(`Gi\u1EEF user h\u1EC7 th\u1ED1ng '${svc.user}' \u2014 user n\xE0y thu\u1ED9c v\u1EC1 '${svc.runAsUnit}', service ch\u1EC9 m\u01B0\u1EE3n \u0111\u1EC3 ch\u1EA1y.`); | |
| } else if (borrowers.length > 0) { | |
| warn( | |
| `GI\u1EEE L\u1EA0I user h\u1EC7 th\u1ED1ng '${svc.user}' \u2014 ${borrowers.length} service kh\xE1c \u0111ang ch\u1EA1y b\u1EB1ng user n\xE0y (--run-as): ${borrowers.map((s) => s.name).join(", ")}. | |
| Xo\xE1 user \u0111i l\xE0 ch\xFAng ch\u1EBFt ngay l\u1EA7n kh\u1EDFi \u0111\u1ED9ng sau.` | |
| ); | |
| } else if (execCapture("id", [svc.user]).code === 0) { | |
| runCmd("userdel", ["-r", svc.user], { silentFail: true }); | |
| ok(`\u0110\xE3 xo\xE1 user h\u1EC7 th\u1ED1ng '${svc.user}'.`); | |
| } | |
| } else { | |
| info(`Gi\u1EEF l\u1EA1i m\xE3 ngu\u1ED3n ${svc.workDir} v\xE0 user h\u1EC7 th\u1ED1ng '${svc.user}'.`); | |
| } | |
| if (opts.database) { | |
| if (svc.dbName) { | |
| try { | |
| dropDatabase(svc.dbName, svc.dbUser); | |
| ok(`\u0110\xE3 xo\xE1 database '${svc.dbName}'.`); | |
| } catch (e) { | |
| warn(`Kh\xF4ng xo\xE1 \u0111\u01B0\u1EE3c database '${svc.dbName}' (${e.message}). H\xE3y t\u1EF1 xo\xE1 sau b\u1EB1ng 'napp db drop ${svc.dbName} --yes --user ${svc.dbUser ?? svc.dbName}'.`); | |
| } | |
| } else { | |
| info("Service kh\xF4ng c\xF3 database ri\xEAng \u2014 b\u1ECF qua."); | |
| } | |
| } else if (svc.dbName) { | |
| info(`Gi\u1EEF l\u1EA1i database '${svc.dbName}'. Mu\u1ED1n xo\xE1 sau: napp db drop ${svc.dbName} --yes --user ${svc.dbUser ?? svc.dbName}`); | |
| } | |
| removeService(name); | |
| ok(`\u0110\xE3 g\u1EE1 service '${name}' kh\u1ECFi napp.`); | |
| const mixAfter = unitMix(); | |
| if (mixAfter.webApps + mixAfter.services > 0) reportBalance(applyNodeHeaps({ restart: true })); | |
| } finally { | |
| release(); | |
| } | |
| } | |
| function listServiceSummaries() { | |
| return Object.values(loadState().services).map((s) => ({ | |
| name: s.name, | |
| port: s.port, | |
| running: execCapture("systemctl", ["is-active", "--quiet", svcSystemdName(s.name)]).code === 0 | |
| })).sort((a, b) => a.name.localeCompare(b.name)); | |
| } | |
| function cmdServiceList() { | |
| const services = Object.values(loadState().services); | |
| if (services.length === 0) { | |
| info("Ch\u01B0a c\xF3 background service n\xE0o \u0111\u01B0\u1EE3c napp qu\u1EA3n l\xFD. D\xF9ng 'napp service create <name> ...' \u0111\u1EC3 t\u1EA1o m\u1EDBi."); | |
| return; | |
| } | |
| section(`Danh s\xE1ch background service (${services.length})`); | |
| for (const svc of services) { | |
| const running = execCapture("systemctl", ["is-active", "--quiet", svcSystemdName(svc.name)]).code === 0; | |
| console.log( | |
| ` ${running ? "\u25CF" : "\u25CB"} ${svc.name.padEnd(30)} ${svc.port !== void 0 ? `port=${String(svc.port).padEnd(6)}` : "no-port".padEnd(11)} ${`${svc.nodeRuntime}/${svc.packageManager ?? "npm"}`.padEnd(10)} user=${svc.user.padEnd(18)} ${svc.runAsUnit ? `run-as=${svc.runAsUnit} ` : ""}${svc.dbName ? `db=${svc.dbName} ` : ""}${svc.redisDbIndex !== void 0 ? `redis=${svc.redisDbIndex} ` : ""}${running ? "\u0111ang ch\u1EA1y" : "\u0110\xC3 D\u1EEANG"}` | |
| ); | |
| } | |
| } | |
| function cmdServiceRestart(name) { | |
| requireRoot(); | |
| validateServiceName(name); | |
| requireService(name); | |
| runCmd("systemctl", ["restart", svcSystemdName(name)]); | |
| ok(`\u0110\xE3 kh\u1EDFi \u0111\u1ED9ng l\u1EA1i ${svcSystemdName(name)}`); | |
| } | |
| function cmdServiceStop(name) { | |
| requireRoot(); | |
| validateServiceName(name); | |
| requireService(name); | |
| runCmd("systemctl", ["stop", svcSystemdName(name)]); | |
| ok(`\u0110\xE3 d\u1EEBng ${svcSystemdName(name)}`); | |
| } | |
| function cmdServiceStart(name) { | |
| requireRoot(); | |
| validateServiceName(name); | |
| requireService(name); | |
| runCmd("systemctl", ["start", svcSystemdName(name)]); | |
| ok(`\u0110\xE3 kh\u1EDFi \u0111\u1ED9ng ${svcSystemdName(name)}`); | |
| } | |
| function cmdServiceLogs(name, opts) { | |
| validateServiceName(name); | |
| requireService(name); | |
| const args = ["-u", svcSystemdName(name), "-n", String(opts.lines), "--no-pager"]; | |
| if (opts.follow) args.push("-f"); | |
| runCmd("journalctl", args); | |
| } | |
| function cmdServiceEnvSet(name, pairs) { | |
| requireRoot(); | |
| validateServiceName(name); | |
| const svc = requireService(name); | |
| const updates = {}; | |
| for (const kv of pairs) { | |
| const eq = kv.indexOf("="); | |
| if (eq === -1) die(`Tham s\u1ED1 ph\u1EA3i theo d\u1EA1ng KEY=VALUE, nh\u1EADn \u0111\u01B0\u1EE3c: '${kv}'`); | |
| const key = kv.slice(0, eq); | |
| validateEnvKey(key); | |
| updates[key] = kv.slice(eq + 1); | |
| } | |
| const svcEnv = `${unitWorkDir(svc.workDir, svc.appDir)}/.env`; | |
| mergeEnvFile(svcEnv, updates, 384); | |
| runCmd("chown", [`${svc.user}:${svc.user}`, svcEnv]); | |
| runCmd("chmod", ["600", svcEnv]); | |
| ok(`\u0110\xE3 c\u1EADp nh\u1EADt .env cho service '${name}'. Ch\u1EA1y 'napp service restart ${name}' \u0111\u1EC3 \xE1p d\u1EE5ng.`); | |
| } | |
| // src/commands/domain.ts | |
| var import_node_fs20 = require("node:fs"); | |
| function regenerateNginxConf(domain2) { | |
| const app2 = requireApp(domain2); | |
| const ngxConf = `${NGINX_AVAILABLE}/${domain2}.conf`; | |
| writeAppLocationsConf(app2); | |
| const bak = `${ngxConf}.napp-bak`; | |
| const had = (0, import_node_fs20.existsSync)(ngxConf); | |
| const hadSsl = had && /ssl_certificate\s/.test((0, import_node_fs20.readFileSync)(ngxConf, "utf8")); | |
| if (had) runCmd("cp", ["-a", ngxConf, bak]); | |
| writeFile(ngxConf, renderAppNginxConf(app2, { ipv6: ipv6Available() }), 420); | |
| const test = execCapture("nginx", ["-t"]); | |
| if (test.code !== 0) { | |
| if (had) runCmd("cp", ["-a", bak, ngxConf], { silentFail: true }); | |
| runCmd("rm", ["-f", bak], { silentFail: true }); | |
| die(`Ki\u1EC3m tra c\u1EA5u h\xECnh nginx th\u1EA5t b\u1EA1i \u2014 \u0110\xC3 HO\xC0N T\xC1C vhost: | |
| ${test.stderr}`); | |
| } | |
| runCmd("systemctl", ["reload", "nginx"]); | |
| runCmd("rm", ["-f", bak], { silentFail: true }); | |
| if (hadSsl) { | |
| warn( | |
| `Vhost v\u1EEBa \u0111\u01B0\u1EE3c render l\u1EA1i n\xEAn kh\u1ED1i SSL do certbot ch\xE8n \u0110\xC3 M\u1EA4T \u2014 site hi\u1EC7n ch\u1EC9 c\xF2n HTTP. | |
| C\u1EA5p l\u1EA1i ngay \u0111\u1EC3 kh\xF4i ph\u1EE5c HTTPS: napp cert issue ${domain2}` + (app2.aliasDomains.length ? ` --extra ${app2.aliasDomains.join(" --extra ")}` : "") | |
| ); | |
| } | |
| } | |
| function cmdDomainAdd(appDomain, alias) { | |
| requireRoot(); | |
| validateDomain(appDomain); | |
| validateDomain(alias); | |
| const app2 = requireApp(appDomain); | |
| if (app2.aliasDomains.includes(alias)) { | |
| warn(`Domain '${alias}' \u0111\xE3 \u0111\u01B0\u1EE3c g\u1EAFn v\u1EDBi app '${appDomain}' t\u1EEB tr\u01B0\u1EDBc.`); | |
| return; | |
| } | |
| app2.aliasDomains.push(alias); | |
| app2.updatedAt = (/* @__PURE__ */ new Date()).toISOString(); | |
| upsertApp(app2); | |
| regenerateNginxConf(appDomain); | |
| ok(`\u0110\xE3 th\xEAm domain ph\u1EE5 '${alias}' -> app '${appDomain}'.`); | |
| info(`Nh\u1EDB tr\u1ECF DNS A c\u1EE7a '${alias}' v\u1EC1 server n\xE0y, r\u1ED3i ch\u1EA1y: napp cert issue ${appDomain} --extra ${alias}`); | |
| } | |
| function cmdDomainRemove(appDomain, alias) { | |
| requireRoot(); | |
| validateDomain(appDomain); | |
| const app2 = requireApp(appDomain); | |
| if (!app2.aliasDomains.includes(alias)) { | |
| die(`Domain '${alias}' kh\xF4ng thu\u1ED9c app '${appDomain}'.`); | |
| } | |
| app2.aliasDomains = app2.aliasDomains.filter((d) => d !== alias); | |
| app2.updatedAt = (/* @__PURE__ */ new Date()).toISOString(); | |
| upsertApp(app2); | |
| regenerateNginxConf(appDomain); | |
| ok(`\u0110\xE3 g\u1EE1 domain ph\u1EE5 '${alias}' kh\u1ECFi app '${appDomain}'.`); | |
| } | |
| function cmdDomainList(appDomain) { | |
| validateDomain(appDomain); | |
| const app2 = requireApp(appDomain); | |
| console.log(`${app2.domain} (ch\xEDnh), www.${app2.domain}${app2.aliasDomains.length ? ", " + app2.aliasDomains.join(", ") : ""}`); | |
| } | |
| // src/commands/cert.ts | |
| function requireCertbot() { | |
| if (!commandExists("certbot")) { | |
| die("certbot ch\u01B0a \u0111\u01B0\u1EE3c c\xE0i. Ch\u1EA1y 'napp check --fix' tr\u01B0\u1EDBc, ho\u1EB7c: apt install certbot python3-certbot-nginx"); | |
| } | |
| } | |
| function cmdCertList() { | |
| requireCertbot(); | |
| runCmd("certbot", ["certificates"]); | |
| } | |
| function cmdCertStatus(domain2) { | |
| requireCertbot(); | |
| if (domain2) { | |
| validateDomain(domain2); | |
| runCmd("certbot", ["certificates", "--cert-name", domain2]); | |
| } else { | |
| runCmd("certbot", ["certificates"]); | |
| } | |
| } | |
| function looksLikeEmail(s) { | |
| return /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(s); | |
| } | |
| async function domainResolves(domain2) { | |
| const { resolve4, resolve6 } = await import("node:dns/promises"); | |
| const has4 = await resolve4(domain2).then((a) => a.length > 0).catch(() => false); | |
| if (has4) return true; | |
| return await resolve6(domain2).then((a) => a.length > 0).catch(() => false); | |
| } | |
| async function cmdCertIssue(domain2, opts) { | |
| requireRoot(); | |
| validateDomain(domain2); | |
| requireApp(domain2); | |
| requireCertbot(); | |
| const candidates = [domain2]; | |
| if (!opts.noWww) candidates.push(`www.${domain2}`); | |
| for (const e of opts.extra) { | |
| validateDomain(e); | |
| candidates.push(e); | |
| } | |
| info("\u0110ang ki\u1EC3m tra DNS c\u1EE7a c\xE1c domain..."); | |
| const resolvable = []; | |
| for (const d of candidates) { | |
| if (await domainResolves(d)) { | |
| resolvable.push(d); | |
| } else { | |
| warn(`'${d}' ch\u01B0a c\xF3 b\u1EA3n ghi DNS (A/AAAA) \u2014 B\u1ECE kh\u1ECFi ch\u1EE9ng ch\u1EC9 l\u1EA7n n\xE0y. Tr\u1ECF DNS cho n\xF3 r\u1ED3i ch\u1EA1y l\u1EA1i \u0111\u1EC3 bao g\u1ED3m.`); | |
| } | |
| } | |
| if (!resolvable.includes(domain2)) { | |
| die( | |
| `Domain ch\xEDnh '${domain2}' ch\u01B0a ph\xE2n gi\u1EA3i DNS \u2014 kh\xF4ng th\u1EC3 ph\xE1t h\xE0nh ch\u1EE9ng ch\u1EC9. | |
| H\xE3y tr\u1ECF b\u1EA3n ghi A c\u1EE7a '${domain2}' v\u1EC1 server n\xE0y (ho\u1EB7c b\u1EADt proxy Cloudflare), \u0111\u1EE3i DNS lan truy\u1EC1n, r\u1ED3i ch\u1EA1y l\u1EA1i.` | |
| ); | |
| } | |
| const args = ["--nginx"]; | |
| for (const d of resolvable) args.push("-d", d); | |
| args.push("--non-interactive", "--agree-tos"); | |
| const email = opts.email ?? getAcmeEmail(); | |
| if (email) { | |
| if (!looksLikeEmail(email)) die(`Email kh\xF4ng h\u1EE3p l\u1EC7: '${email}'`); | |
| args.push("--email", email); | |
| } else if (opts.registerWithoutEmail) { | |
| args.push("--register-unsafely-without-email"); | |
| } else { | |
| die( | |
| `C\u1EA7n email cho Let's Encrypt (\u0111\u1EC3 nh\u1EADn c\u1EA3nh b\xE1o h\u1EBFt h\u1EA1n/b\u1EA3o m\u1EADt). Truy\u1EC1n --email <email>, | |
| ho\u1EB7c --no-email \u0111\u1EC3 \u0111\u0103ng k\xFD KH\xD4NG email (kh\xF4ng khuy\u1EBFn ngh\u1ECB). V\xED d\u1EE5: | |
| sudo napp cert issue ${domain2} --email ban@example.com` | |
| ); | |
| } | |
| args.push(opts.redirect === false ? "--no-redirect" : "--redirect"); | |
| info(`\u0110ang ph\xE1t h\xE0nh ch\u1EE9ng ch\u1EC9 SSL cho ${domain2} (certbot s\u1EBD t\u1EF1 c\u1EADp nh\u1EADt nginx)...`); | |
| runCmd("certbot", args); | |
| if (email) setAcmeEmail(email); | |
| ok("Ho\xE0n t\u1EA5t. certbot \u0111\xE3 c\xE0i ch\u1EE9ng ch\u1EC9 v\xE0 t\u1EF1 l\xEAn l\u1ECBch gia h\u1EA1n (systemd timer certbot.timer)."); | |
| } | |
| function cmdCertRenew(domain2, opts) { | |
| requireRoot(); | |
| requireCertbot(); | |
| const args = ["renew"]; | |
| if (domain2) { | |
| validateDomain(domain2); | |
| args.push("--cert-name", domain2); | |
| } | |
| if (opts.force) args.push("--force-renewal"); | |
| info(domain2 ? `\u0110ang gia h\u1EA1n ch\u1EE9ng ch\u1EC9 cho ${domain2}...` : "\u0110ang gia h\u1EA1n t\u1EA5t c\u1EA3 ch\u1EE9ng ch\u1EC9 s\u1EAFp h\u1EBFt h\u1EA1n..."); | |
| runCmd("certbot", args); | |
| ok("Ho\xE0n t\u1EA5t gia h\u1EA1n."); | |
| } | |
| async function cmdCertRevoke(domain2, opts) { | |
| requireRoot(); | |
| validateDomain(domain2); | |
| requireCertbot(); | |
| if (!opts.yes) { | |
| const readline2 = await import("node:readline/promises"); | |
| const rl2 = readline2.createInterface({ input: process.stdin, output: process.stdout }); | |
| const ans = await rl2.question( | |
| `Thao t\xE1c n\xE0y s\u1EBD THU H\u1ED2I v\xE0 XO\xC1 ch\u1EE9ng ch\u1EC9 c\u1EE7a '${domain2}'. Website s\u1EBD m\u1EA5t HTTPS h\u1EE3p l\u1EC7 cho t\u1EDBi khi ph\xE1t h\xE0nh l\u1EA1i. | |
| Ti\u1EBFp t\u1EE5c? [y/N] ` | |
| ); | |
| rl2.close(); | |
| if (!/^y(es)?$/i.test(ans.trim())) { | |
| info("\u0110\xE3 hu\u1EF7."); | |
| return; | |
| } | |
| } | |
| runCmd("certbot", ["revoke", "--cert-name", domain2, "--delete-after-revoke", "--non-interactive"]); | |
| ok(`\u0110\xE3 thu h\u1ED3i v\xE0 xo\xE1 ch\u1EE9ng ch\u1EC9 c\u1EE7a ${domain2}.`); | |
| } | |
| // src/commands/db.ts | |
| function cmdDbCreate(name, userOpt) { | |
| requireRoot(); | |
| validateDbName(name); | |
| const user = userOpt ?? name; | |
| validateDbName(user); | |
| const created = createDatabase(name, user); | |
| ok(`\u0110\xE3 t\u1EA1o database '${created.name}' + user '${created.user}'@'localhost'`); | |
| console.log(` M\u1EADt kh\u1EA9u: ${created.password}`); | |
| console.log(" H\xC3Y L\u01AFU M\u1EACT KH\u1EA8U N\xC0Y NGAY \u2014 n\xF3 ch\u1EC9 hi\u1EC3n th\u1ECB m\u1ED9t l\u1EA7n duy nh\u1EA5t."); | |
| } | |
| async function cmdDbDrop(name, opts) { | |
| requireRoot(); | |
| validateDbName(name); | |
| if (!opts.yes) { | |
| const readline2 = await import("node:readline/promises"); | |
| const rl2 = readline2.createInterface({ input: process.stdin, output: process.stdout }); | |
| const ans = await rl2.question(`Xo\xE1 database '${name}' v\u0129nh vi\u1EC5n? H\xE0nh \u0111\u1ED9ng kh\xF4ng th\u1EC3 ho\xE0n t\xE1c. [y/N] `); | |
| rl2.close(); | |
| if (!/^y(es)?$/i.test(ans.trim())) { | |
| info("\u0110\xE3 hu\u1EF7."); | |
| return; | |
| } | |
| } | |
| dropDatabase(name, opts.user); | |
| ok(`\u0110\xE3 xo\xE1 database '${name}'.`); | |
| } | |
| function cmdDbList() { | |
| const bin = mysqlBin(); | |
| const res = execCapture(bin, [ | |
| "-N", | |
| "-e", | |
| "SELECT SCHEMA_NAME FROM information_schema.SCHEMATA WHERE SCHEMA_NAME NOT IN ('information_schema','performance_schema','mysql','sys') ORDER BY SCHEMA_NAME" | |
| ]); | |
| if (res.code !== 0) die(`Kh\xF4ng li\u1EC7t k\xEA \u0111\u01B0\u1EE3c database: ${res.stderr}`); | |
| const names = res.stdout.trim().split("\n").filter(Boolean); | |
| section(`Database (${names.length})`); | |
| for (const n of names) console.log(` - ${n}`); | |
| } | |
| function cmdDbBackup(name) { | |
| requireRoot(); | |
| validateDbName(name); | |
| if (!dbExists(name)) die(`Database '${name}' kh\xF4ng t\u1ED3n t\u1EA1i.`); | |
| ensureDir(`${BACKUP_ROOT}/db`, 488); | |
| const ts = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-"); | |
| const outPath = `${BACKUP_ROOT}/db/${name}-${ts}.sql.gz`; | |
| info(`\u0110ang dump database '${name}'...`); | |
| dumpDatabase(name, outPath); | |
| ok(`\u0110\xE3 l\u01B0u backup t\u1EA1i ${outPath}`); | |
| } | |
| // src/commands/redis.ts | |
| function requireRedisCli() { | |
| if (!commandExists("redis-cli")) { | |
| die("redis-cli ch\u01B0a \u0111\u01B0\u1EE3c c\xE0i. Ch\u1EA1y 'napp check --fix' \u0111\u1EC3 c\xE0i Redis."); | |
| } | |
| } | |
| function cmdRedisInfo() { | |
| requireRedisCli(); | |
| const res = execCapture("redis-cli", ["INFO", "memory"]); | |
| console.log(res.stdout); | |
| } | |
| function cmdRedisAllocations() { | |
| const s = loadState(); | |
| section(`C\u1EA5p ph\xE1t Redis DB (0-${REDIS_DB_MAX - 1})`); | |
| const apps = Object.values(s.apps).filter((a) => a.redisDbIndex !== void 0); | |
| if (apps.length === 0) { | |
| info("Ch\u01B0a c\xF3 app n\xE0o d\xF9ng Redis DB ri\xEAng."); | |
| return; | |
| } | |
| for (const a of apps) console.log(` DB #${a.redisDbIndex} -> ${a.domain}`); | |
| } | |
| async function cmdRedisFlush(dbIndex, opts) { | |
| requireRoot(); | |
| requireRedisCli(); | |
| if (!Number.isInteger(dbIndex) || dbIndex < 0 || dbIndex >= REDIS_DB_MAX) { | |
| die(`DB index kh\xF4ng h\u1EE3p l\u1EC7: ${dbIndex} (0-${REDIS_DB_MAX - 1})`); | |
| } | |
| if (!opts.yes) { | |
| const readline2 = await import("node:readline/promises"); | |
| const rl2 = readline2.createInterface({ input: process.stdin, output: process.stdout }); | |
| const ans = await rl2.question(`Xo\xE1 TO\xC0N B\u1ED8 d\u1EEF li\u1EC7u trong Redis DB #${dbIndex}? [y/N] `); | |
| rl2.close(); | |
| if (!/^y(es)?$/i.test(ans.trim())) { | |
| info("\u0110\xE3 hu\u1EF7."); | |
| return; | |
| } | |
| } | |
| runCmd("redis-cli", ["-n", String(dbIndex), "FLUSHDB"]); | |
| ok(`\u0110\xE3 flush Redis DB #${dbIndex}.`); | |
| } | |
| // src/commands/backup.ts | |
| var import_node_fs21 = require("node:fs"); | |
| var NAPP_BIN_PATH = "/usr/local/bin/napp"; | |
| var BACKUP_TIMER_NAME = "napp-backup"; | |
| var DEFAULT_RETENTION_DAYS = 14; | |
| function timestamp() { | |
| return (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-"); | |
| } | |
| function pruneOldBackups(dir, policy, log) { | |
| if (!(0, import_node_fs21.existsSync)(dir)) return; | |
| const files = (0, import_node_fs21.readdirSync)(dir).map((f) => ({ f, path: `${dir}/${f}`, mtime: (0, import_node_fs21.statSync)(`${dir}/${f}`).mtimeMs })).sort((a, b) => b.mtime - a.mtime); | |
| const remove = /* @__PURE__ */ new Set(); | |
| if (policy.keepDays > 0) { | |
| const cutoff = Date.now() - policy.keepDays * 864e5; | |
| for (const f of files) if (f.mtime < cutoff) remove.add(f.path); | |
| } | |
| if (policy.keepCount && policy.keepCount > 0) { | |
| for (const f of files.slice(policy.keepCount)) remove.add(f.path); | |
| } | |
| for (const f of files) { | |
| if (remove.has(f.path)) { | |
| (0, import_node_fs21.unlinkSync)(f.path); | |
| log(`\u0110\xE3 xo\xE1 backup c\u0169: ${f.f}`); | |
| } | |
| } | |
| } | |
| function cmdBackupRun(opts) { | |
| requireRoot(); | |
| const log = opts.quiet ? () => { | |
| } : info; | |
| const s = loadState(); | |
| const ts = timestamp(); | |
| const policy = { keepDays: opts.keepDays, keepCount: opts.keepCount }; | |
| if (opts.target === "db" || opts.target === "all") { | |
| if (!dbServiceRunning()) { | |
| warn("MariaDB/MySQL kh\xF4ng ch\u1EA1y \u2014 b\u1ECF qua backup database."); | |
| } else { | |
| ensureDir(`${BACKUP_ROOT}/db`, 488); | |
| if (opts.database) { | |
| if (!dbExists(opts.database)) die(`Database '${opts.database}' kh\xF4ng t\u1ED3n t\u1EA1i.`); | |
| const outPath = `${BACKUP_ROOT}/db/${opts.database}-${ts}.sql.gz`; | |
| log(`\u0110ang backup database '${opts.database}'...`); | |
| dumpDatabase(opts.database, outPath); | |
| ok(`Database: ${outPath}`); | |
| } else { | |
| log("\u0110ang backup to\xE0n b\u1ED9 database (mysqldump --all-databases, n\xE9n gzip)..."); | |
| const outPath = `${BACKUP_ROOT}/db/all-databases-${ts}.sql.gz`; | |
| dumpAllDatabases(outPath); | |
| ok(`Database: ${outPath}`); | |
| } | |
| pruneOldBackups(`${BACKUP_ROOT}/db`, policy, log); | |
| } | |
| } | |
| if (opts.target === "files" || opts.target === "all") { | |
| ensureDir(`${BACKUP_ROOT}/files`, 488); | |
| const apps = Object.values(s.apps); | |
| if (apps.length === 0) { | |
| log("Kh\xF4ng c\xF3 app n\xE0o \u0111\u1EC3 backup m\xE3 ngu\u1ED3n."); | |
| } | |
| for (const app2 of apps) { | |
| const outPath = `${BACKUP_ROOT}/files/${app2.domain}-${ts}.tar.gz`; | |
| log(`\u0110ang n\xE9n m\xE3 ngu\u1ED3n '${app2.domain}'...`); | |
| runCmd("tar", [ | |
| "--exclude=node_modules", | |
| "--exclude=.git", | |
| "-czf", | |
| outPath, | |
| "-C", | |
| "/var/www", | |
| app2.domain | |
| ]); | |
| ok(`M\xE3 ngu\u1ED3n: ${outPath}`); | |
| } | |
| pruneOldBackups(`${BACKUP_ROOT}/files`, policy, log); | |
| } | |
| } | |
| function humanSize(bytes) { | |
| if (bytes >= 1 << 30) return `${(bytes / (1 << 30)).toFixed(1)} GB`; | |
| if (bytes >= 1 << 20) return `${(bytes / (1 << 20)).toFixed(1)} MB`; | |
| if (bytes >= 1 << 10) return `${(bytes / (1 << 10)).toFixed(1)} KB`; | |
| return `${bytes} B`; | |
| } | |
| function cmdBackupList() { | |
| section("Danh s\xE1ch backup"); | |
| let total = 0; | |
| for (const sub of ["db", "files"]) { | |
| const dir = `${BACKUP_ROOT}/${sub}`; | |
| if (!(0, import_node_fs21.existsSync)(dir)) continue; | |
| console.log(` ${sub}/ (${dir})`); | |
| const files = (0, import_node_fs21.readdirSync)(dir).sort(); | |
| if (files.length === 0) console.log(" (tr\u1ED1ng)"); | |
| for (const f of files) { | |
| const size = (0, import_node_fs21.statSync)(`${dir}/${f}`).size; | |
| total += size; | |
| console.log(` - ${f.padEnd(48)} ${humanSize(size)}`); | |
| } | |
| } | |
| console.log(` | |
| T\u1ED5ng dung l\u01B0\u1EE3ng backup: ${humanSize(total)}`); | |
| } | |
| function cmdBackupSchedule(opts) { | |
| requireRoot(); | |
| const onCalendar = timeToDailyOnCalendar(opts.time); | |
| const scriptCmd = `${NAPP_BIN_PATH} backup run --target ${opts.target} --keep-days ${opts.keepDays} --quiet`; | |
| writeManagedUnit( | |
| `${SYSTEMD_DIR}/${BACKUP_TIMER_NAME}.service`, | |
| renderBackupService(`/bin/bash -lc ${JSON.stringify(scriptCmd)}`), | |
| { authoritative: ["ExecStart"] } | |
| ); | |
| writeFile(`${SYSTEMD_DIR}/${BACKUP_TIMER_NAME}.timer`, renderBackupTimer(onCalendar), 420); | |
| runCmd("systemctl", ["daemon-reload"]); | |
| runCmd("systemctl", ["enable", "--now", `${BACKUP_TIMER_NAME}.timer`]); | |
| ok(`\u0110\xE3 l\xEAn l\u1ECBch backup h\xE0ng ng\xE0y l\xFAc ${opts.time} (gi\u1EEF b\u1EA3n trong ${opts.keepDays} ng\xE0y, target=${opts.target}).`); | |
| info(`Ki\u1EC3m tra l\u1ECBch ch\u1EA1y: systemctl list-timers ${BACKUP_TIMER_NAME}.timer`); | |
| } | |
| function cmdBackupUnschedule() { | |
| requireRoot(); | |
| runCmd("systemctl", ["disable", "--now", `${BACKUP_TIMER_NAME}.timer`], { silentFail: true }); | |
| runCmd("rm", ["-f", `${SYSTEMD_DIR}/${BACKUP_TIMER_NAME}.service`, `${SYSTEMD_DIR}/${BACKUP_TIMER_NAME}.timer`], { silentFail: true }); | |
| runCmd("systemctl", ["daemon-reload"]); | |
| ok("\u0110\xE3 g\u1EE1 l\u1ECBch backup t\u1EF1 \u0111\u1ED9ng."); | |
| } | |
| // src/commands/firewall.ts | |
| var import_node_fs22 = require("node:fs"); | |
| // src/lib/cloudflare.ts | |
| var FALLBACK_IPV4 = [ | |
| "173.245.48.0/20", | |
| "103.21.244.0/22", | |
| "103.22.200.0/22", | |
| "103.31.4.0/22", | |
| "141.101.64.0/18", | |
| "108.162.192.0/18", | |
| "190.93.240.0/20", | |
| "188.114.96.0/20", | |
| "197.234.240.0/22", | |
| "198.41.128.0/17", | |
| "162.158.0.0/15", | |
| "104.16.0.0/13", | |
| "104.24.0.0/14", | |
| "172.64.0.0/13", | |
| "131.0.72.0/22" | |
| ]; | |
| var FALLBACK_IPV6 = ["2400:cb00::/32", "2606:4700::/32", "2803:f800::/32", "2405:b500::/32", "2405:8100::/32", "2a06:98c0::/29", "2c0f:f248::/32"]; | |
| async function fetchText(url) { | |
| const res = await fetch(url, { signal: AbortSignal.timeout(1e4) }); | |
| if (!res.ok) throw new Error(`HTTP ${res.status} khi t\u1EA3i ${url}`); | |
| return res.text(); | |
| } | |
| async function fetchCloudflareIpRanges() { | |
| try { | |
| const [v4, v6] = await Promise.all([fetchText("https://www.cloudflare.com/ips-v4"), fetchText("https://www.cloudflare.com/ips-v6")]); | |
| const ipv4 = v4.trim().split("\n").map((l) => l.trim()).filter(Boolean); | |
| const ipv6 = v6.trim().split("\n").map((l) => l.trim()).filter(Boolean); | |
| if (ipv4.length === 0 || ipv6.length === 0) throw new Error("Danh s\xE1ch IP tr\u1EA3 v\u1EC1 r\u1ED7ng"); | |
| return { ipv4, ipv6 }; | |
| } catch (e) { | |
| console.error(`[C\u1EA2NH B\xC1O] Kh\xF4ng t\u1EA3i \u0111\u01B0\u1EE3c d\u1EA3i IP Cloudflare m\u1EDBi nh\u1EA5t (${e.message}) \u2014 d\xF9ng b\u1EA3n d\u1EF1 ph\xF2ng \u0111\xF3ng g\xF3i s\u1EB5n trong napp.`); | |
| return { ipv4: FALLBACK_IPV4, ipv6: FALLBACK_IPV6 }; | |
| } | |
| } | |
| // src/commands/firewall.ts | |
| function detectSshPort() { | |
| const candidates = ["/etc/ssh/sshd_config"]; | |
| const dropInDir = "/etc/ssh/sshd_config.d"; | |
| if ((0, import_node_fs22.existsSync)(dropInDir)) { | |
| for (const f of (0, import_node_fs22.readdirSync)(dropInDir)) candidates.push(`${dropInDir}/${f}`); | |
| } | |
| let port = 22; | |
| for (const file of candidates) { | |
| if (!(0, import_node_fs22.existsSync)(file)) continue; | |
| const content = (0, import_node_fs22.readFileSync)(file, "utf8"); | |
| const m = content.match(/^\s*Port\s+(\d+)/m); | |
| if (m) port = parseInt(m[1], 10); | |
| } | |
| return port; | |
| } | |
| async function cmdFirewallSync(opts) { | |
| requireRoot(); | |
| if (!commandExists("ufw")) { | |
| die("UFW ch\u01B0a \u0111\u01B0\u1EE3c c\xE0i. Ch\u1EA1y 'napp check --fix' tr\u01B0\u1EDBc."); | |
| } | |
| const log = opts.quiet ? () => { | |
| } : info; | |
| const sshPort = opts.sshPort ?? detectSshPort(); | |
| section("\u0110\u1ED3ng b\u1ED9 t\u01B0\u1EDDng l\u1EEDa (UFW)"); | |
| log(`Ph\xE1t hi\u1EC7n c\u1ED5ng SSH hi\u1EC7n t\u1EA1i: ${sshPort}`); | |
| if (!opts.yes && !opts.quiet) { | |
| const readline2 = await import("node:readline/promises"); | |
| const rl2 = readline2.createInterface({ input: process.stdin, output: process.stdout }); | |
| warn( | |
| "AN TO\xC0N: h\xE3y gi\u1EEF m\u1ED9t phi\xEAn SSH/console TH\u1EE8 HAI \u0111ang m\u1EDF song song tr\u01B0\u1EDBc khi ti\u1EBFp t\u1EE5c \u2014 n\u1EBFu c\u1ED5ng SSH b\u1ECB d\xF2 sai ho\u1EB7c rule b\u1ECB c\u1EA5u h\xECnh nh\u1EA7m, phi\xEAn hi\u1EC7n t\u1EA1i c\xF3 th\u1EC3 b\u1ECB kho\xE1 ngay l\u1EADp t\u1EE9c." | |
| ); | |
| const ans = await rl2.question( | |
| `S\u1EBD c\u1EA5u h\xECnh UFW: deny incoming m\u1EB7c \u0111\u1ECBnh, allow outgoing, allow SSH c\u1ED5ng ${sshPort}, ${opts.restrictToCloudflare ? "allow 80/443 CH\u1EC8 t\u1EEB IP Cloudflare" : "allow 80/443 cho m\u1ECDi ng\u01B0\u1EDDi"}. | |
| Ti\u1EBFp t\u1EE5c? [y/N] ` | |
| ); | |
| rl2.close(); | |
| if (!/^y(es)?$/i.test(ans.trim())) { | |
| info("\u0110\xE3 hu\u1EF7."); | |
| return; | |
| } | |
| } | |
| const statusNumbered = execCapture("ufw", ["status", "numbered"]).stdout; | |
| const oldRuleNumbers = statusNumbered.split("\n").filter((l) => /napp/i.test(l)).map((l) => l.match(/^\[\s*(\d+)\]/)?.[1]).filter((n) => !!n).map((n) => parseInt(n, 10)).sort((a, b) => b - a); | |
| for (const n of oldRuleNumbers) { | |
| runCmd("bash", ["-lc", `yes | ufw delete ${n} >/dev/null 2>&1 || true`], { silentFail: true }); | |
| } | |
| runCmd("ufw", ["default", "deny", "incoming"]); | |
| runCmd("ufw", ["default", "allow", "outgoing"]); | |
| runCmd("ufw", ["allow", `${sshPort}/tcp`, "comment", "napp: SSH"]); | |
| if (opts.restrictToCloudflare) { | |
| log("\u0110ang t\u1EA3i d\u1EA3i IP Cloudflare hi\u1EC7n t\u1EA1i..."); | |
| const ranges = await fetchCloudflareIpRanges(); | |
| const hasIpv6 = ipv6Available(); | |
| const ips = hasIpv6 ? [...ranges.ipv4, ...ranges.ipv6] : ranges.ipv4; | |
| if (!hasIpv6) warn("M\xE1y ch\u1EE7 kh\xF4ng c\xF3 ng\u0103n x\u1EBFp IPv6 \u2014 b\u1ECF qua c\xE1c d\u1EA3i IP Cloudflare IPv6, ch\u1EC9 whitelist IPv4."); | |
| for (const ip of ips) { | |
| runCmd("ufw", ["allow", "from", ip, "to", "any", "port", "80,443", "proto", "tcp", "comment", "napp: Cloudflare"]); | |
| } | |
| ok(`(N\xE2ng cao) \u0110\xE3 kho\xE1 origin: 80/443 CH\u1EC8 nh\u1EADn t\u1EEB ${ips.length} d\u1EA3i IP Cloudflare.`); | |
| warn( | |
| "Ch\u1EBF \u0111\u1ED9 kho\xE1 origin n\xE0y KH\xD4NG c\u1EA7n cho vi\u1EC7c l\u1EA5y IP client th\u1EADt (\u0111\xF3 l\xE0 vi\u1EC7c c\u1EE7a nginx real-IP qua `napp cloudflare sync`). Ch\u1EC9 b\u1EADt n\u1EBFu mu\u1ED1n ch\u1ED1ng bypass th\u1EB3ng v\xE0o origin IP, V\xC0 m\u1ECDi domain \u0111\u1EC1u b\u1EADt proxy (orange cloud) tr\xEAn Cloudflare \u2014 domain n\xE0o kh\xF4ng qua proxy s\u1EBD b\u1ECB ch\u1EB7n." | |
| ); | |
| } else { | |
| runCmd("ufw", ["allow", "80,443/tcp", "comment", "napp: HTTP/HTTPS"]); | |
| log("80/443 m\u1EDF cho m\u1ECDi IP. IP client th\u1EADt do nginx kh\xF4i ph\u1EE5c qua `napp cloudflare sync` (real-IP t\u1EEB header CF-Connecting-IP)."); | |
| } | |
| for (const p of opts.extraPorts) { | |
| runCmd("ufw", ["allow", `${p}/tcp`, "comment", "napp: extra"]); | |
| } | |
| runCmd("bash", ["-lc", "yes | ufw enable"]); | |
| runCmd("ufw", ["reload"]); | |
| ok("UFW \u0111\xE3 \u0111\u01B0\u1EE3c \u0111\u1ED3ng b\u1ED9 v\xE0 b\u1EADt."); | |
| } | |
| function cmdFirewallStatus() { | |
| runCmd("ufw", ["status", "verbose"]); | |
| } | |
| // src/templates/fail2ban.ts | |
| var FAIL2BAN_JAIL_PATH = "/etc/fail2ban/jail.local"; | |
| var FAIL2BAN_NAPP_FILTER_PATH = "/etc/fail2ban/filter.d/napp-ratelimit.conf"; | |
| var FAIL2BAN_SCANNER_FILTER_PATH = "/etc/fail2ban/filter.d/napp-scanner.conf"; | |
| var FILE_BACKEND = "backend = auto"; | |
| function renderJailLocal(sshPort) { | |
| return `# Managed by napp \u2014 T\u1EF0 \u0110\u1ED8NG SINH RA b\u1EDFi \`napp fail2ban setup\`. | |
| [DEFAULT] | |
| bantime = 3600 | |
| findtime = 600 | |
| maxretry = 3 | |
| backend = systemd | |
| banaction = ufw | |
| [sshd] | |
| enabled = true | |
| port = ${sshPort} | |
| filter = sshd | |
| logpath = /var/log/auth.log | |
| maxretry = 3 | |
| bantime = 86400 | |
| [nginx-botsearch] | |
| enabled = true | |
| ${FILE_BACKEND} | |
| port = http,https | |
| filter = nginx-botsearch | |
| logpath = /var/log/nginx/*access.log | |
| maxretry = 2 | |
| [nginx-http-auth] | |
| enabled = true | |
| ${FILE_BACKEND} | |
| port = http,https | |
| filter = nginx-http-auth | |
| logpath = /var/log/nginx/*error.log | |
| maxretry = 3 | |
| [nginx-limit-req] | |
| enabled = true | |
| ${FILE_BACKEND} | |
| port = http,https | |
| filter = nginx-limit-req | |
| logpath = /var/log/nginx/*error.log | |
| maxretry = 5 | |
| # Jail ri\xEAng cho c\xE1c app Node qu\u1EA3n l\xFD b\u1EDFi napp: ch\u1EB7n IP spam l\u1ED7i 502/504/429 | |
| # (th\u01B0\u1EDDng l\xE0 backend app b\u1ECB treo/qu\xE1 t\u1EA3i ho\u1EB7c b\u1ECB d\xF2 brute-force API). | |
| [napp-ratelimit] | |
| enabled = true | |
| ${FILE_BACKEND} | |
| port = http,https | |
| filter = napp-ratelimit | |
| logpath = /var/log/nginx/*access.log | |
| maxretry = 30 | |
| findtime = 60 | |
| bantime = 1800 | |
| # Qu\xE9t l\u1ED7 h\u1ED5ng CMS/framework PHP (/wp-login.php, /phpmyadmin/, /cgi-bin/...). | |
| # | |
| # Jail n\xE0y ch\u1EB7t h\u01A1n h\u1EB3n c\xE1c jail tr\xEAn, v\xE0 \u0110\u01AF\u1EE2C PH\xC9P ch\u1EB7t, v\xEC n\xF3 \u0111\u1ECDc m\u1ED9t file log | |
| # RI\xCANG ch\u1EC9 ch\u1EE9a nh\u1EEFng request nginx \u0110\xC3 ch\u1EB7n b\u1EB1ng 444: m\u1ECDi d\xF2ng trong \u0111\xF3 ch\u1EAFc | |
| # ch\u1EAFn l\xE0 scanner, kh\xF4ng c\xF3 traffic th\u1EADt n\xE0o l\u1EABn v\xE0o \u0111\u1EC3 m\xE0 ban nh\u1EA7m. | |
| # | |
| # \u0110\xE2y m\u1EDBi l\xE0 ch\u1ED7 ti\u1EBFt ki\u1EC7m t\xE0i nguy\xEAn TH\u1EACT. 'return 444' ch\u1EC9 b\u1ECF \u0111\u01B0\u1EE3c v\xF2ng qua | |
| # Node \u2014 ph\u1EA7n \u0111\u1EAFt nh\u1EA5t c\u1EE7a m\u1ED9t request qu\xE9t l\xE0 b\u1EAFt tay TCP + TLS, v\xE0 nginx \u0111\xE3 tr\u1EA3 | |
| # xong kho\u1EA3n \u0111\xF3 tr\u01B0\u1EDBc khi k\u1ECBp nh\xECn th\u1EA5y URI. Ban \u1EDF t\u01B0\u1EDDng l\u1EEDa th\xEC g\xF3i tin b\u1ECB b\u1ECF | |
| # TR\u01AF\u1EDAC c\u1EA3 b\u1EAFt tay. | |
| # | |
| # \u26A0\uFE0F Site sau Cloudflare proxy: banaction 'ufw' ban IP TH\u1EACT c\u1EE7a client (nh\u1EDD | |
| # real_ip l\u1EA5y t\u1EEB CF-Connecting-IP), nh\u01B0ng g\xF3i tin l\u1EA1i \u0111\u1EBFn t\u1EEB IP edge c\u1EE7a | |
| # Cloudflare n\xEAn lu\u1EADt ufw kh\xF4ng bao gi\u1EDD kh\u1EDBp \u2014 ban th\xE0nh v\xF4 hi\u1EC7u m\xE0 kh\xF4ng b\xE1o | |
| # l\u1ED7i. V\u1EDBi c\xE1c site \u0111\xF3, h\xE3y ch\u1EB7n \u1EDF WAF c\u1EE7a Cloudflare; ph\u1EA7n ch\u1EB7n 444 + log s\u1EA1ch | |
| # \u1EDF nginx th\xEC v\u1EABn c\xF3 t\xE1c d\u1EE5ng b\xECnh th\u01B0\u1EDDng. | |
| [napp-scanner] | |
| enabled = true | |
| ${FILE_BACKEND} | |
| port = http,https | |
| filter = napp-scanner | |
| logpath = ${NGINX_SCANNER_LOG} | |
| maxretry = 3 | |
| findtime = 600 | |
| bantime = 86400 | |
| `; | |
| } | |
| function renderNappRatelimitFilter() { | |
| return `# Managed by napp | |
| [Definition] | |
| failregex = ^<HOST> .* ".*" (502|504|429) .*$ | |
| ignoreregex = | |
| `; | |
| } | |
| function renderNappScannerFilter() { | |
| return `# Managed by napp | |
| [Definition] | |
| failregex = ^<HOST> - \\S+ \\[ | |
| ignoreregex = | |
| [Init] | |
| # Ng\xE0y n\u1EB1m trong c\u1EB7p [] sau hostname \u2014 n\xF3i r\xF5 ra thay v\xEC \u0111\u1EC3 fail2ban t\u1EF1 \u0111o\xE1n, | |
| # v\xEC \u0111o\xE1n tr\u01B0\u1EE3t th\xEC jail im l\u1EB7ng kh\xF4ng ban ai c\u1EA3. | |
| datepattern = ^[^\\[]*\\[({DATE}) | |
| `; | |
| } | |
| // src/commands/fail2ban.ts | |
| function detectSshPortForF2b() { | |
| const res = execCapture("bash", [ | |
| "-lc", | |
| "grep -h -riE '^\\s*Port\\s+[0-9]+' /etc/ssh/sshd_config /etc/ssh/sshd_config.d/*.conf 2>/dev/null | tail -1 | grep -oE '[0-9]+'" | |
| ]); | |
| const n = parseInt(res.stdout.trim(), 10); | |
| return Number.isFinite(n) ? n : 22; | |
| } | |
| function cmdFail2banSetup(opts) { | |
| requireRoot(); | |
| if (!commandExists("fail2ban-client")) { | |
| die("fail2ban ch\u01B0a \u0111\u01B0\u1EE3c c\xE0i. Ch\u1EA1y 'napp check --fix' tr\u01B0\u1EDBc."); | |
| } | |
| const sshPort = opts.sshPort ?? detectSshPortForF2b(); | |
| info(`\xC1p c\u1EA5u h\xECnh fail2ban (c\u1ED5ng SSH: ${sshPort})...`); | |
| writeFile(FAIL2BAN_NAPP_FILTER_PATH, renderNappRatelimitFilter(), 420); | |
| writeFile(FAIL2BAN_SCANNER_FILTER_PATH, renderNappScannerFilter(), 420); | |
| writeFile(FAIL2BAN_JAIL_PATH, renderJailLocal(sshPort), 420); | |
| runCmd("touch", [NGINX_SCANNER_LOG]); | |
| runCmd("chown", ["root:adm", NGINX_SCANNER_LOG], { silentFail: true }); | |
| runCmd("chmod", ["640", NGINX_SCANNER_LOG], { silentFail: true }); | |
| runCmd("systemctl", ["enable", "fail2ban"]); | |
| runCmd("systemctl", ["restart", "fail2ban"]); | |
| ok("\u0110\xE3 \xE1p c\u1EA5u h\xECnh fail2ban: sshd, nginx-botsearch, nginx-http-auth, nginx-limit-req, napp-ratelimit, napp-scanner."); | |
| info(`\u2022 Jail 'napp-scanner' \u0111\u1ECDc ${NGINX_SCANNER_LOG} \u2014 m\u1ECDi d\xF2ng trong \u0111\xF3 \u0111\u1EC1u l\xE0 request qu\xE9t \u0111\xE3 b\u1ECB nginx ch\u1EB7n, n\xEAn ban r\u1EA5t ch\u1EB7t (3 l\u1EA7n / 10 ph\xFAt -> c\u1EA5m 1 ng\xE0y) m\xE0 kh\xF4ng s\u1EE3 ban nh\u1EA7m.`); | |
| info("\u2022 C\xE1c jail nginx nay ghi \u0111\xE8 'backend = auto': backend systemd \u1EDF [DEFAULT] khi\u1EBFn fail2ban B\u1ECE QUA logpath v\xE0 \u0111i \u0111\u1ECDc journal \u2014 nginx ghi log ra file n\xEAn c\xE1c jail \u0111\xF3 tr\u01B0\u1EDBc \u0111\xE2y kh\xF4ng th\u1EA5y g\xEC \u0111\u1EC3 \u0111\u1ECDc."); | |
| info("\u2022 Ch\u01B0a b\u1EADt ch\u1EB7n qu\xE9t l\u1ED7 h\u1ED5ng \u1EDF nginx? Ch\u1EA1y: napp nginx scanblock"); | |
| } | |
| function cmdFail2banStatus() { | |
| runCmd("fail2ban-client", ["status"]); | |
| } | |
| function cmdFail2banUnban(jail, ip) { | |
| requireRoot(); | |
| runCmd("fail2ban-client", ["set", jail, "unbanip", ip]); | |
| } | |
| // src/commands/tune.ts | |
| var import_node_fs23 = require("node:fs"); | |
| function printPriorityPlan(serviceHeapMB) { | |
| const rc = detectResourceControl(); | |
| section("\u01AFu ti\xEAn t\xE0i nguy\xEAn: web app > background service"); | |
| console.log(` CPUWeight : web app ${CPU_WEIGHT_WEB} \xB7 service ${CPU_WEIGHT_SERVICE} (m\u1EB7c \u0111\u1ECBnh systemd l\xE0 100)`); | |
| console.log( | |
| ` -> KHI TRANH CH\u1EA4P, web app \u0111\u01B0\u1EE3c ~${(CPU_WEIGHT_WEB / CPU_WEIGHT_SERVICE).toFixed(0)}x ph\u1EA7n CPU. Kh\xF4ng tranh ch\u1EA5p th\xEC kh\xF4ng ai b\u1ECB gi\u1EDBi h\u1EA1n.` | |
| ); | |
| console.log(` IOWeight : web app ${IO_WEIGHT_WEB} \xB7 service ${IO_WEIGHT_SERVICE}`); | |
| console.log( | |
| rc.cgroupV2 ? ` MemoryHigh (service) : ${serviceMemoryHighMB(serviceHeapMB)} MB \u2014 gi\u1EDBi h\u1EA1n M\u1EC0M (throttle + thu h\u1ED3i, KH\xD4NG gi\u1EBFt ti\u1EBFn tr\xECnh)` : ` MemoryHigh (service) : b\u1ECF qua (c\u1EA7n cgroup v2)` | |
| ); | |
| console.log(); | |
| console.log(" Th\u1EF1c t\u1EBF tr\xEAn m\xE1y n\xE0y:"); | |
| for (const line of formatResourceControl(rc)) console.log(line); | |
| } | |
| function cmdTuneShow() { | |
| const hw = detectHardware(); | |
| const st = loadState(); | |
| const mix = { webApps: Object.keys(st.apps).length, services: Object.keys(st.services).length }; | |
| section("Ph\u1EA7n c\u1EE9ng ph\xE1t hi\u1EC7n \u0111\u01B0\u1EE3c"); | |
| console.log(formatHardware(hw)); | |
| const plan = computeTuningPlan(hw, void 0, mix, st.serviceHeapWeight); | |
| console.log(); | |
| section("K\u1EBF ho\u1EA1ch t\u1ED1i \u01B0u (ch\u01B0a \xE1p d\u1EE5ng \u2014 d\xF9ng `napp tune apply`)"); | |
| console.log(` InnoDB buffer pool : ${plan.innodbBufferPoolMB} MB`); | |
| console.log(` MariaDB max_connections : ${plan.maxConnections}`); | |
| console.log(` Redis maxmemory : ${plan.redisMaxMemoryMB} MB (maxmemory-policy: noeviction \u2014 b\u1EAFt bu\u1ED9c cho BullMQ)`); | |
| console.log(` nginx worker_connections : ${plan.workerConnections}`); | |
| console.log( | |
| mix.webApps + mix.services === 0 ? ` Node heap : ch\u01B0a c\xF3 app/service n\xE0o \u2014 heap \u0111\u01B0\u1EE3c chia l\u1EA1i m\u1ED7i l\u1EA7n t\u1EA1o ho\u1EB7c xo\xE1 \u0111\u01A1n v\u1ECB` : ` Node heap : web app ${plan.heap.webMB} MB \xB7 background service ${plan.heap.serviceMB} MB (${mix.webApps} app + ${mix.services} service, tr\u1ECDng s\u1ED1 service ${plan.heap.serviceWeight}; ch\u1EC9 runtime=node)` | |
| ); | |
| console.log(); | |
| printPriorityPlan(plan.heap.serviceMB); | |
| } | |
| function patchNginxMainConf(workerConnections) { | |
| const path = "/etc/nginx/nginx.conf"; | |
| if (!(0, import_node_fs23.existsSync)(path)) { | |
| warn(`Kh\xF4ng t\xECm th\u1EA5y ${path} \u2014 b\u1ECF qua patch worker_processes/worker_connections.`); | |
| return; | |
| } | |
| let content = (0, import_node_fs23.readFileSync)(path, "utf8"); | |
| if (/^\s*worker_processes\s+/m.test(content)) { | |
| content = content.replace(/^\s*worker_processes\s+.*/m, "worker_processes auto; # managed by napp tune"); | |
| } else { | |
| content = `worker_processes auto; # managed by napp tune | |
| ${content}`; | |
| } | |
| if (/worker_connections\s+\d+/m.test(content)) { | |
| content = content.replace(/worker_connections\s+\d+;/m, `worker_connections ${workerConnections}; # managed by napp tune`); | |
| } | |
| writeFile(path, content, 420); | |
| } | |
| function verifyEffectivePriority(st) { | |
| const units = [ | |
| ...Object.keys(st.apps).map((d) => ({ unit: `${serviceNameFor(d)}.service`, want: CPU_WEIGHT_WEB })), | |
| ...Object.keys(st.services).map((n) => ({ unit: `${svcSystemdName(n)}.service`, want: CPU_WEIGHT_SERVICE })) | |
| ]; | |
| const mismatched = []; | |
| const inert = []; | |
| let verified = 0; | |
| for (const { unit, want } of units) { | |
| const cg = execCapture("systemctl", ["show", "-p", "ControlGroup", "--value", unit]); | |
| const path = cg.stdout.trim(); | |
| if (cg.code !== 0 || !path) continue; | |
| const weightFile = `/sys/fs/cgroup${path}/cpu.weight`; | |
| if (!(0, import_node_fs23.existsSync)(weightFile)) { | |
| inert.push(unit); | |
| continue; | |
| } | |
| const actual = (0, import_node_fs23.readFileSync)(weightFile, "utf8").trim(); | |
| if (actual === String(want)) verified++; | |
| else mismatched.push(`${unit} (kernel \u0111ang \xE1p ${actual}, mong \u0111\u1EE3i ${want})`); | |
| } | |
| if (verified > 0 && mismatched.length === 0 && inert.length === 0) { | |
| ok(`\u0110\xE3 \u0111\u1ED1i chi\u1EBFu v\u1EDBi kernel: cpu.weight \u0111\xFAng tr\xEAn c\u1EA3 ${verified} \u0111\u01A1n v\u1ECB \u0111ang ch\u1EA1y \u2014 \u01B0u ti\xEAn CPU c\xF3 hi\u1EC7u l\u1EF1c TH\u1EACT.`); | |
| return; | |
| } | |
| if (inert.length > 0) { | |
| warn( | |
| `${inert.length} \u0111\u01A1n v\u1ECB KH\xD4NG c\xF3 'cpu.weight' trong cgroup (${inert.join(", ")}) \u2014 cgroup controller 'cpu' ch\u01B0a b\u1EADt cho nh\xE1nh n\xE0y, n\xEAn d\xF2ng CPUWeight trong unit hi\u1EC7n KH\xD4NG c\xF3 t\xE1c d\u1EE5ng (systemctl v\u1EABn in ra gi\xE1 tr\u1ECB \u0111\xE3 c\u1EA5u h\xECnh, \u0111\u1EEBng tin n\xF3). | |
| Th\u01B0\u1EDDng t\u1EF1 h\u1EBFt sau: sudo systemctl daemon-reload && sudo systemctl restart <unit>. | |
| N\u1EBFu v\u1EABn kh\xF4ng c\xF3: kernel/cgroup c\u1EE7a m\xE1y n\xE0y kh\xF4ng c\u1EA5p controller 'cpu' (hay g\u1EB7p tr\xEAn VPS n\u1EC1n OpenVZ/LXC).` | |
| ); | |
| } | |
| if (mismatched.length > 0) { | |
| warn(`cpu.weight trong kernel ch\u01B0a kh\u1EDBp v\u1EDBi c\u1EA5u h\xECnh: | |
| ${mismatched.map((m) => ` - ${m}`).join("\n")} | |
| Th\u1EED: sudo systemctl restart <unit>.`); | |
| } | |
| } | |
| async function cmdTuneApply(opts) { | |
| requireRoot(); | |
| const hw = detectHardware(); | |
| const st = loadState(); | |
| const mix = { webApps: Object.keys(st.apps).length, services: Object.keys(st.services).length }; | |
| if (opts.serviceWeight !== void 0) { | |
| if (!(opts.serviceWeight >= 0.1 && opts.serviceWeight <= 1)) { | |
| die(`--service-weight ph\u1EA3i n\u1EB1m trong kho\u1EA3ng 0.1\u20131 (nh\u1EADn \u0111\u01B0\u1EE3c: ${opts.serviceWeight}). 1 = chia \u0111\u1EC1u nh\u01B0 tr\u01B0\u1EDBc, 0.5 = web app g\u1EA5p \u0111\xF4i service.`); | |
| } | |
| st.serviceHeapWeight = opts.serviceWeight; | |
| saveState(st); | |
| } | |
| const plan = computeTuningPlan(hw, opts.dbRamPercent, mix, st.serviceHeapWeight); | |
| section("T\u1ED1i \u01B0u theo ph\u1EA7n c\u1EE9ng th\u1EF1c t\u1EBF"); | |
| console.log(formatHardware(hw)); | |
| console.log(); | |
| console.log(` InnoDB buffer pool -> ${plan.innodbBufferPoolMB} MB`); | |
| console.log(` Redis maxmemory -> ${plan.redisMaxMemoryMB} MB (maxmemory-policy: noeviction \u2014 b\u1EAFt bu\u1ED9c cho BullMQ)`); | |
| console.log(` nginx worker_connections -> ${plan.workerConnections}`); | |
| console.log( | |
| ` Node heap -> web app ${plan.heap.webMB} MB \xB7 background service ${plan.heap.serviceMB} MB (${mix.webApps} app + ${mix.services} service, tr\u1ECDng s\u1ED1 service ${plan.heap.serviceWeight})` | |
| ); | |
| console.log(); | |
| printPriorityPlan(plan.heap.serviceMB); | |
| if (hw.diskFreeGB > 0 && hw.diskFreeGB < 5) { | |
| warn(`\u1ED4 \u0111\u0129a tr\u1ED1ng ch\u1EC9 c\xF2n ${hw.diskFreeGB} GB \u2014 ch\xFA \xFD dung l\u01B0\u1EE3ng cho log/AOF Redis/backup.`); | |
| } | |
| console.log(); | |
| if (!opts.yes) { | |
| const readline2 = await import("node:readline/promises"); | |
| const rl2 = readline2.createInterface({ input: process.stdin, output: process.stdout }); | |
| const ans = await rl2.question("\xC1p d\u1EE5ng c\u1EA5u h\xECnh tr\xEAn v\xE0 kh\u1EDFi \u0111\u1ED9ng l\u1EA1i nginx/MariaDB/Redis + c\xE1c app? [y/N] "); | |
| rl2.close(); | |
| if (!/^y(es)?$/i.test(ans.trim())) { | |
| info("\u0110\xE3 hu\u1EF7. Kh\xF4ng thay \u0111\u1ED5i g\xEC."); | |
| return; | |
| } | |
| } | |
| writeFile(SYSCTL_TUNING_PATH, renderSysctlTuning(), 420); | |
| const sysctlRes = runCmd("sysctl", ["--system"], { silentFail: true }); | |
| if (sysctlRes.code !== 0) { | |
| warn( | |
| "M\u1ED9t s\u1ED1 tham s\u1ED1 sysctl kh\xF4ng \xE1p \u0111\u01B0\u1EE3c tr\xEAn kernel/h\u1EA1 t\u1EA7ng hi\u1EC7n t\u1EA1i (th\u01B0\u1EDDng do thi\u1EBFu module, v\xED d\u1EE5 sch_fq cho net.core.default_qdisc tr\xEAn v\xE0i container/kernel t\u1ED1i gi\u1EA3n) \u2014 c\xE1c tham s\u1ED1 c\xF2n l\u1EA1i v\u1EABn \u0111\xE3 \u0111\u01B0\u1EE3c \xE1p." | |
| ); | |
| } else { | |
| ok("\u0110\xE3 \xE1p sysctl tuning."); | |
| } | |
| if (commandExists("nginx")) { | |
| ensureDir("/etc/nginx/conf.d", 493); | |
| writeFile(NGINX_TUNING_CONF, renderNginxTuningConf(hw.cpuCores, hw.tier), 420); | |
| patchNginxMainConf(plan.workerConnections); | |
| const test = execCapture("nginx", ["-t"]); | |
| if (test.code !== 0) { | |
| warn(`C\u1EA5u h\xECnh nginx sau khi tune c\xF3 l\u1ED7i c\xFA ph\xE1p \u2014 \u0110\xC3 GHI FILE nh\u01B0ng KH\xD4NG reload: | |
| ${test.stderr}`); | |
| } else if (!opts.skipRestart) { | |
| runCmd("systemctl", ["reload", "nginx"]); | |
| ok("\u0110\xE3 \xE1p tuning cho nginx v\xE0 reload."); | |
| } | |
| } else { | |
| warn("nginx ch\u01B0a c\xE0i \u2014 b\u1ECF qua."); | |
| } | |
| if (commandExists("mysqld") || commandExists("mariadbd")) { | |
| ensureDir("/etc/mysql/conf.d", 493); | |
| writeFile(MARIADB_TUNING_PATH, renderMariadbTuning(hw, plan), 420); | |
| if (!opts.skipRestart && isServiceActive("mariadb")) { | |
| runCmd("systemctl", ["restart", "mariadb"]); | |
| ok("\u0110\xE3 \xE1p tuning cho MariaDB v\xE0 kh\u1EDFi \u0111\u1ED9ng l\u1EA1i."); | |
| } else if (!opts.skipRestart && isServiceActive("mysql")) { | |
| runCmd("systemctl", ["restart", "mysql"]); | |
| ok("\u0110\xE3 \xE1p tuning cho MySQL v\xE0 kh\u1EDFi \u0111\u1ED9ng l\u1EA1i."); | |
| } else { | |
| ok(`\u0110\xE3 ghi ${MARIADB_TUNING_PATH} \u2014 service ch\u01B0a ch\u1EA1y n\xEAn ch\u01B0a restart.`); | |
| } | |
| } else { | |
| warn("MariaDB/MySQL ch\u01B0a c\xE0i \u2014 b\u1ECF qua."); | |
| } | |
| if (commandExists("redis-server")) { | |
| ensureDir("/etc/redis/conf.d", 493); | |
| writeFile(REDIS_TUNING_PATH, renderRedisTuning(hw, plan), 420); | |
| const mainConf = "/etc/redis/redis.conf"; | |
| if ((0, import_node_fs23.existsSync)(mainConf)) { | |
| const content = (0, import_node_fs23.readFileSync)(mainConf, "utf8"); | |
| if (!content.includes("conf.d/*.conf")) { | |
| writeFile(mainConf, content + "\ninclude /etc/redis/conf.d/*.conf\n", 416); | |
| } | |
| } | |
| if (!opts.skipRestart && isServiceActive("redis-server")) { | |
| runCmd("systemctl", ["restart", "redis-server"]); | |
| ok("\u0110\xE3 \xE1p tuning cho Redis v\xE0 kh\u1EDFi \u0111\u1ED9ng l\u1EA1i."); | |
| } else { | |
| ok(`\u0110\xE3 ghi ${REDIS_TUNING_PATH} \u2014 service ch\u01B0a ch\u1EA1y n\xEAn ch\u01B0a restart.`); | |
| } | |
| } else { | |
| warn("Redis ch\u01B0a c\xE0i \u2014 b\u1ECF qua."); | |
| } | |
| const unitCount = mix.webApps + mix.services; | |
| if (unitCount > 0) { | |
| if (opts.syncUnits) { | |
| const heap = syncAllUnits({ restart: !opts.skipRestart }); | |
| ok( | |
| `\u0110\xE3 render l\u1EA1i unit systemd cho ${unitCount} \u0111\u01A1n v\u1ECB (heap: web ${heap.webMB} MB \xB7 service ${heap.serviceMB} MB)` + (opts.skipRestart ? " \u2014 ch\u01B0a restart do --skip-restart." : " v\xE0 kh\u1EDFi \u0111\u1ED9ng l\u1EA1i.") | |
| ); | |
| } else { | |
| const res = applyNodeHeaps({ restart: !opts.skipRestart }); | |
| ok( | |
| `\u0110\xE3 c\xE2n \u0111\u1ED1i heap V8 + \u01B0u ti\xEAn t\xE0i nguy\xEAn cho ${unitCount} \u0111\u01A1n v\u1ECB (ch\u1EC9 s\u1EEDa d\xF2ng --max-old-space-size, CPUWeight, IOWeight, MemoryHigh \u2014 ph\u1EA7n c\xF2n l\u1EA1i c\u1EE7a unit gi\u1EEF nguy\xEAn)` + (opts.skipRestart ? " \u2014 ch\u01B0a restart do --skip-restart." : ".") | |
| ); | |
| reportBalance(res); | |
| if (opts.skipRestart && res.heapChanged.length > 0) { | |
| info(`Heap m\u1EDBi ch\u1EC9 \xE1p sau khi restart: ${res.heapChanged.join(", ")} (NODE_OPTIONS ch\u1EC9 \u0111\u01B0\u1EE3c \u0111\u1ECDc l\xFAc ti\u1EBFn tr\xECnh kh\u1EDFi \u0111\u1ED9ng).`); | |
| } | |
| info("Mu\u1ED1n \u0111\u1ED3ng b\u1ED9 lu\xF4n ph\u1EA7n hardening/template m\u1EDBi xu\u1ED1ng unit c\u0169: napp tune apply --sync-units"); | |
| } | |
| verifyEffectivePriority(st); | |
| } | |
| console.log(); | |
| ok("Ho\xE0n t\u1EA5t. Ch\u1EA1y l\u1EA1i l\u1EC7nh n\xE0y b\u1EA5t c\u1EE9 khi n\xE0o n\xE2ng c\u1EA5p ph\u1EA7n c\u1EE9ng server."); | |
| } | |
| // src/commands/mem.ts | |
| var import_node_fs24 = require("node:fs"); | |
| var NAPP_BIN_PATH2 = "/usr/local/bin/napp"; | |
| var mb2 = (b) => b === void 0 ? "\u2014" : `${Math.round(b / 1048576)} MB`; | |
| function sleepMs(ms) { | |
| Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms); | |
| } | |
| function verdictLabel(v) { | |
| if (v === "leak") return colorText("red", "R\xD2 R\u1EC8"); | |
| if (v === "watch") return colorText("yellow", "THEO D\xD5I"); | |
| if (v === "ok") return colorText("green", "\u1ED5n \u0111\u1ECBnh"); | |
| return colorText("dim", "ch\u01B0a \u0111\u1EE7 d\u1EEF li\u1EC7u"); | |
| } | |
| function cmdMemStatus() { | |
| const units = allUnits(); | |
| if (units.length === 0) { | |
| info("Ch\u01B0a c\xF3 app/service n\xE0o \u0111\u1EC3 theo d\xF5i."); | |
| return; | |
| } | |
| const samples = readSamples(); | |
| section("B\u1ED9 nh\u1EDB t\u1EEBng \u0111\u01A1n v\u1ECB node"); | |
| for (const ref of units) { | |
| const m = readUnitMemory(ref); | |
| const t = analyseTrend(samples, m.unit); | |
| const head = `${m.unit}${m.kind === "service" ? " (service)" : ""}`; | |
| console.log(` | |
| ${colorText("blue", head)}${m.active ? "" : colorText("red", " [\u0110ANG D\u1EEANG]")}`); | |
| console.log(` B\u1ED9 nh\u1EDB \u1EA9n danh (heap/stack) : ${mb2(m.anonBytes)}${m.peakBytes ? ` \xB7 \u0111\u1EC9nh: ${mb2(m.peakBytes)}` : ""}`); | |
| if (m.memoryHighBytes !== void 0) { | |
| const throttled = (m.highEvents ?? 0) > 0; | |
| console.log( | |
| ` MemoryHigh (gi\u1EDBi h\u1EA1n m\u1EC1m) : ${mb2(m.memoryHighBytes)} \xB7 s\u1ED1 l\u1EA7n b\u1ECB throttle: ` + (throttled ? colorText("yellow", String(m.highEvents)) : "0") | |
| ); | |
| } | |
| const r = m.restarts; | |
| console.log(` systemd \u0111\xE3 kh\u1EDFi \u0111\u1ED9ng l\u1EA1i : ${r > 0 ? colorText("yellow", String(r)) : "0"} l\u1EA7n${m.lastResult ? ` \xB7 l\u1EA7n d\u1EEBng g\u1EA7n nh\u1EA5t: ${colorText("red", m.lastResult)}` : ""}`); | |
| if (t.verdict === "insufficient") { | |
| console.log(` Xu h\u01B0\u1EDBng : ${verdictLabel(t.verdict)}${t.samples > 0 ? ` (${t.samples} m\u1EABu, ${t.spanHours}h \u2014 c\u1EA7n \xEDt nh\u1EA5t 8 m\u1EABu tr\u1EA3i 6h)` : " (ch\u01B0a l\u1EA5y m\u1EABu l\u1EA7n n\xE0o)"}`); | |
| } else { | |
| console.log( | |
| ` Xu h\u01B0\u1EDBng (${t.spanHours}h, ${t.samples} m\u1EABu) : ${verdictLabel(t.verdict)} \u2014 ${t.baselineMB} MB \u2192 ${t.currentMB} MB (${t.growthMB >= 0 ? "+" : ""}${t.growthMB} MB, ${t.growthPct >= 0 ? "+" : ""}${t.growthPct}%, ~${t.mbPerDay} MB/ng\xE0y)` | |
| ); | |
| } | |
| } | |
| reportFindings(units, samples); | |
| } | |
| function reportFindings(units, samples) { | |
| console.log(); | |
| const leaks = []; | |
| const restarted = []; | |
| for (const ref of units) { | |
| const t = analyseTrend(samples, ref.unit); | |
| if (t.verdict === "leak" || t.verdict === "watch") leaks.push(t); | |
| const m = readUnitMemory(ref); | |
| if (m.restarts > 0) restarted.push(`${m.unit} (${m.restarts} l\u1EA7n${m.lastResult ? `, g\u1EA7n nh\u1EA5t: ${m.lastResult}` : ""})`); | |
| } | |
| if (restarted.length > 0) { | |
| warn( | |
| `systemd \u0111\xE3 \xE2m th\u1EA7m kh\u1EDFi \u0111\u1ED9ng l\u1EA1i: ${restarted.join(", ")}. | |
| Unit napp \u0111\u1EC1u c\xF3 'Restart=always', n\xEAn app ch\u1EA1m tr\u1EA7n heap s\u1EBD CH\u1EBET r\u1ED3i t\u1EF1 s\u1ED1ng l\u1EA1i m\xE0 kh\xF4ng ai hay. | |
| Xem nguy\xEAn nh\xE2n: journalctl -u <unit> --since '7 days ago' | grep -iE 'out of memory|oom|heap'` | |
| ); | |
| } | |
| for (const t of leaks) { | |
| const line = `${t.unit}: b\u1ED9 nh\u1EDB t\u0103ng ${t.growthMB} MB (+${t.growthPct}%) trong ${t.spanHours}h k\u1EC3 t\u1EEB l\u1EA7n kh\u1EDFi \u0111\u1ED9ng g\u1EA7n nh\u1EA5t, ~${t.mbPerDay} MB/ng\xE0y.`; | |
| if (t.verdict === "leak") warn(`${line} | |
| Ch\u1EE5p heap \u0111\u1EC3 t\xECm th\u1EE7 ph\u1EA1m: sudo napp mem snapshot <domain|name>`); | |
| else info(`${line} (ch\u01B0a k\u1EBFt lu\u1EADn \u2014 theo d\xF5i th\xEAm)`); | |
| } | |
| const stray = strayHeapSnapshots(); | |
| if (stray.length > 0) { | |
| warn( | |
| `T\xECm th\u1EA5y file .heapsnapshot c\xF2n s\xF3t trong th\u01B0 m\u1EE5c app \u2014 \u0111\xE2y l\xE0 B\u1EB0NG CH\u1EE8NG app \u0111\xE3 ch\u1EA1m tr\u1EA7n heap: | |
| ` + stray.map((s) => ` - ${s.id}: ${s.files.map((f) => `${f.name} (${f.mb} MB)`).join(", ")} | |
| t\u1EA1i ${s.dir}`).join("\n") + ` | |
| T\u1EA3i v\u1EC1 m\xE1y r\u1ED3i m\u1EDF b\u1EB1ng Chrome DevTools > Memory > Load. Nh\u1EDB XO\xC1 \u0111i sau: file n\xE0y r\u1EA5t to. | |
| File 0 MB ho\u1EB7c nh\u1ECF b\u1EA5t th\u01B0\u1EDDng so v\u1EDBi heap l\xE0 b\u1EA3n C\u1EE4T \u2014 Node b\u1ECB d\u1EEBng gi\u1EEFa ch\u1EEBng l\xFAc \u0111ang ghi | |
| (ghi xong m\u1ED9t snapshot c\xF3 th\u1EC3 m\u1EA5t v\xE0i ph\xFAt). B\u1EA3n c\u1EE5t v\u1EABn l\xE0 file .heapsnapshot nh\u01B0ng kh\xF4ng m\u1EDF \u0111\u01B0\u1EE3c.` | |
| ); | |
| } | |
| if (restarted.length === 0 && leaks.length === 0 && stray.length === 0) { | |
| ok("Kh\xF4ng th\u1EA5y d\u1EA5u hi\u1EC7u r\xF2 r\u1EC9 b\u1ED9 nh\u1EDB."); | |
| } | |
| if (samples.length === 0) { | |
| info(`Ch\u01B0a c\xF3 d\u1EEF li\u1EC7u xu h\u01B0\u1EDBng. B\u1EADt l\u1EA5y m\u1EABu \u0111\u1ECBnh k\u1EF3: sudo napp mem watch`); | |
| } | |
| } | |
| function cmdMemTrend() { | |
| const samples = readSamples(); | |
| if (samples.length === 0) { | |
| info(`Ch\u01B0a c\xF3 m\u1EABu n\xE0o \u1EDF ${MEMWATCH_LOG}. B\u1EADt l\u1EA5y m\u1EABu \u0111\u1ECBnh k\u1EF3: sudo napp mem watch`); | |
| return; | |
| } | |
| section(`Xu h\u01B0\u1EDBng b\u1ED9 nh\u1EDB (${samples.length} m\u1EABu)`); | |
| for (const ref of allUnits()) { | |
| const t = analyseTrend(samples, ref.unit); | |
| if (t.verdict === "insufficient") { | |
| console.log(` ${ref.unit.padEnd(34)} ${verdictLabel(t.verdict)} (${t.samples} m\u1EABu, ${t.spanHours}h)`); | |
| continue; | |
| } | |
| console.log( | |
| ` ${ref.unit.padEnd(34)} ${verdictLabel(t.verdict)} ${t.baselineMB} \u2192 ${t.currentMB} MB (${t.growthMB >= 0 ? "+" : ""}${t.growthMB} MB / ${t.spanHours}h, ~${t.mbPerDay} MB/ng\xE0y)` + (t.restartsSeen > 0 ? colorText("dim", ` [\u0111\xE3 restart ${t.restartsSeen} l\u1EA7n trong log \u2014 ch\u1EC9 t\xEDnh t\u1EEB l\u1EA7n g\u1EA7n nh\u1EA5t]`) : "") | |
| ); | |
| } | |
| reportFindings(allUnits(), samples); | |
| } | |
| function cmdMemSample(opts = {}) { | |
| requireRoot(); | |
| const n = takeSample(); | |
| if (!opts.quiet) { | |
| if (n === 0) info("Kh\xF4ng c\xF3 \u0111\u01A1n v\u1ECB n\xE0o \u0111ang ch\u1EA1y \u0111\u1EC3 l\u1EA5y m\u1EABu."); | |
| else ok(`\u0110\xE3 ghi ${n} m\u1EABu v\xE0o ${MEMWATCH_LOG}.`); | |
| } | |
| } | |
| function intervalToOnCalendar(minutes) { | |
| if (minutes < 1 || minutes > 1440) die(`--interval ph\u1EA3i trong kho\u1EA3ng 1\u20131440 ph\xFAt (nh\u1EADn \u0111\u01B0\u1EE3c: ${minutes}).`); | |
| if (minutes < 60 && 60 % minutes === 0) return `*:0/${minutes}`; | |
| if (minutes === 60) return "hourly"; | |
| if (minutes % 60 === 0) return `*-*-* 0/${minutes / 60}:00:00`; | |
| die(`--interval ${minutes} kh\xF4ng chia \u0111\u1EC1u \u0111\u01B0\u1EE3c v\xE0o gi\u1EDD. H\xE3y ch\u1ECDn 1, 5, 10, 15, 20, 30, 60, ho\u1EB7c b\u1ED9i s\u1ED1 c\u1EE7a 60.`); | |
| } | |
| function cmdMemWatch(opts) { | |
| requireRoot(); | |
| const onCalendar = intervalToOnCalendar(opts.interval); | |
| writeManagedUnit(`${SYSTEMD_DIR}/${MEMWATCH_TIMER_NAME}.service`, renderMemwatchService(NAPP_BIN_PATH2), { authoritative: ["ExecStart"] }); | |
| writeFile(`${SYSTEMD_DIR}/${MEMWATCH_TIMER_NAME}.timer`, renderMemwatchTimer(onCalendar), 420); | |
| runCmd("systemctl", ["daemon-reload"]); | |
| runCmd("systemctl", ["enable", "--now", `${MEMWATCH_TIMER_NAME}.timer`]); | |
| takeSample(); | |
| ok(`\u0110\xE3 b\u1EADt l\u1EA5y m\u1EABu b\u1ED9 nh\u1EDB m\u1ED7i ${opts.interval} ph\xFAt (${onCalendar}).`); | |
| info(`\u2022 D\u1EEF li\u1EC7u: ${MEMWATCH_LOG} (t\u1EF1 gi\u1EEF 20000 m\u1EABu g\u1EA7n nh\u1EA5t, kh\xF4ng c\u1EA7n logrotate)`); | |
| info(`\u2022 Xem k\u1EBFt qu\u1EA3: napp mem trend \u2014 c\u1EA7n \xCDT NH\u1EA4T 6 gi\u1EDD d\u1EEF li\u1EC7u m\u1EDBi k\u1EBFt lu\u1EADn \u0111\u01B0\u1EE3c g\xEC`); | |
| info(`\u2022 L\u1ECBch ch\u1EA1y: systemctl list-timers ${MEMWATCH_TIMER_NAME}.timer`); | |
| } | |
| function cmdMemUnwatch() { | |
| requireRoot(); | |
| runCmd("systemctl", ["disable", "--now", `${MEMWATCH_TIMER_NAME}.timer`], { silentFail: true }); | |
| runCmd("rm", ["-f", `${SYSTEMD_DIR}/${MEMWATCH_TIMER_NAME}.service`, `${SYSTEMD_DIR}/${MEMWATCH_TIMER_NAME}.timer`], { silentFail: true }); | |
| runCmd("systemctl", ["daemon-reload"]); | |
| ok("\u0110\xE3 t\u1EAFt l\u1EA5y m\u1EABu b\u1ED9 nh\u1EDB."); | |
| info(`D\u1EEF li\u1EC7u c\u0169 v\u1EABn gi\u1EEF \u1EDF ${MEMWATCH_LOG} (xo\xE1 tay n\u1EBFu kh\xF4ng c\u1EA7n).`); | |
| } | |
| function liveNodeOptions(pid) { | |
| const p = `/proc/${pid}/environ`; | |
| if (!(0, import_node_fs24.existsSync)(p)) return void 0; | |
| try { | |
| for (const kv of (0, import_node_fs24.readFileSync)(p, "utf8").split("\0")) { | |
| if (kv.startsWith("NODE_OPTIONS=")) return kv.slice("NODE_OPTIONS=".length); | |
| } | |
| return ""; | |
| } catch { | |
| return void 0; | |
| } | |
| } | |
| function freeBytes(dir) { | |
| const res = execCapture("df", ["-B1", "--output=avail", dir]); | |
| if (res.code !== 0) return void 0; | |
| const n = parseInt(res.stdout.trim().split("\n").pop() ?? "", 10); | |
| return Number.isFinite(n) ? n : void 0; | |
| } | |
| function snapshotsIn(dir) { | |
| try { | |
| return new Set((0, import_node_fs24.readdirSync)(dir).filter((f) => f.endsWith(".heapsnapshot"))); | |
| } catch { | |
| return /* @__PURE__ */ new Set(); | |
| } | |
| } | |
| async function cmdMemSnapshot(id, opts = {}) { | |
| requireRoot(); | |
| const ref = allUnits().find((u) => u.id === id || u.unit === id); | |
| if (!ref) die(`Kh\xF4ng t\xECm th\u1EA5y app/service '${id}'. Xem danh s\xE1ch: napp app list \xB7 napp service list`); | |
| const m = readUnitMemory(ref); | |
| if (!m.active || !m.mainPid) die(`'${id}' kh\xF4ng ch\u1EA1y (ho\u1EB7c kh\xF4ng l\u1EA5y \u0111\u01B0\u1EE3c PID) \u2014 kh\xF4ng c\xF3 heap n\xE0o \u0111\u1EC3 ch\u1EE5p.`); | |
| const st = loadState(); | |
| const workDir = st.apps[ref.id] ? unitWorkDir(st.apps[ref.id].webRoot, st.apps[ref.id].appDir) : unitWorkDir(st.services[ref.id].workDir, st.services[ref.id].appDir); | |
| const nodeOpts = liveNodeOptions(m.mainPid); | |
| if (nodeOpts === void 0) { | |
| die( | |
| `Kh\xF4ng \u0111\u1ECDc \u0111\u01B0\u1EE3c /proc/${m.mainPid}/environ n\xEAn KH\xD4NG th\u1EC3 x\xE1c nh\u1EADn c\u1EDD '--heapsnapshot-signal'. | |
| napp T\u1EEA CH\u1ED0I g\u1EEDi SIGUSR2: n\u1EBFu c\u1EDD \u0111\xF3 kh\xF4ng c\xF3 hi\u1EC7u l\u1EF1c, t\xEDn hi\u1EC7u n\xE0y GI\u1EBET ti\u1EBFn tr\xECnh.` | |
| ); | |
| } | |
| if (!nodeOpts.includes("--heapsnapshot-signal=SIGUSR2")) { | |
| die( | |
| `'${id}' \u0111ang ch\u1EA1y KH\xD4NG c\xF3 c\u1EDD '--heapsnapshot-signal=SIGUSR2'. | |
| G\u1EEDi SIGUSR2 l\xFAc n\xE0y s\u1EBD GI\u1EBET ti\u1EBFn tr\xECnh (h\xE0nh vi m\u1EB7c \u0111\u1ECBnh), n\xEAn napp d\u1EEBng l\u1EA1i \u1EDF \u0111\xE2y. | |
| NODE_OPTIONS \u0111ang c\xF3 hi\u1EC7u l\u1EF1c: ${nodeOpts || "(r\u1ED7ng)"} | |
| B\u1EADt r\u1ED3i kh\u1EDFi \u0111\u1ED9ng l\u1EA1i app: | |
| sudo napp app set ${id} --leak-guard (ho\u1EB7c: napp service set ${id} --leak-guard) | |
| N\u1EBFu b\u1EA1n \u0110\xC3 b\u1EADt m\xE0 v\u1EABn th\u1EA5y d\xF2ng n\xE0y: '.env' c\u1EE7a app \u0111ang \u0111\u1EB7t NODE_OPTIONS v\xE0 ghi \u0111\xE8 c\u1EA5u h\xECnh c\u1EE7a napp | |
| (napp c\u1ED1 \xFD \u0111\u1EB7t NODE_OPTIONS TR\u01AF\u1EDAC EnvironmentFile \u0111\u1EC3 b\u1EA1n ghi \u0111\xE8 \u0111\u01B0\u1EE3c).` | |
| ); | |
| } | |
| const need = (m.anonBytes ?? 0) * 2.5; | |
| const avail = freeBytes(workDir); | |
| if (avail !== void 0 && need > 0 && avail < need) { | |
| die( | |
| `Kh\xF4ng \u0111\u1EE7 ch\u1ED7 tr\u1ED1ng. Heap hi\u1EC7n ~${mb2(m.anonBytes)}, file snapshot th\u01B0\u1EDDng l\u1EDBn G\u1EA4P ~2 L\u1EA6N heap (\u0111o th\u1EF1c t\u1EBF: 47 MB -> 82 MB \xB7 96 MB -> 184 MB \xB7 128 MB -> 237 MB), c\u1EA7n ~${mb2(need)} nh\u01B0ng ch\u1EC9 c\xF2n ${mb2(avail)} \u1EDF ${workDir}.` | |
| ); | |
| } | |
| section(`Ch\u1EE5p heap snapshot: ${id}`); | |
| info(`\u0110\u01A1n v\u1ECB : ${m.unit} (PID ${m.mainPid})`); | |
| info(`Heap hi\u1EC7n : ${mb2(m.anonBytes)} -> file \u01B0\u1EDBc t\xEDnh ~${mb2((m.anonBytes ?? 0) * 2)}`); | |
| info(`Ghi v\xE0o : ${workDir} (th\u01B0 m\u1EE5c l\xE0m vi\u1EC7c c\u1EE7a app \u2014 Node lu\xF4n ghi v\xE0o CWD, kh\xF4ng \u0111\u1ED5i \u0111\u01B0\u1EE3c)`); | |
| warn( | |
| `Vi\u1EC7c n\xE0y KH\xD4NG NHANH. \u0110o tr\xEAn m\xE1y th\u1EADt: heap 96 MB -> file 184 MB, m\u1EA5t 176 GI\xC2Y \u0111\u1EC3 ghi xong. | |
| V8 d\u1EEBng ti\u1EBFn tr\xECnh \u0111\u1EC3 duy\u1EC7t heap, r\u1ED3i ghi ra \u0111\u0129a; heap c\xE0ng l\u1EDBn c\xE0ng l\xE2u. | |
| V\u1EDBi app web \u0111ang ph\u1EE5c v\u1EE5 traffic: ch\u1EA1y v\xE0o gi\u1EDD th\u1EA5p \u0111i\u1EC3m, ho\u1EB7c ch\u1EE5p worker tr\u01B0\u1EDBc. | |
| \u0110\u1EEANG restart/stop \u0111\u01A1n v\u1ECB trong l\xFAc \u0111ang ghi \u2014 file s\u1EBD C\u1EE4T v\xE0 kh\xF4ng ph\xE2n t\xEDch \u0111\u01B0\u1EE3c.` | |
| ); | |
| if (!opts.yes) { | |
| const readline2 = await import("node:readline/promises"); | |
| const rl2 = readline2.createInterface({ input: process.stdin, output: process.stdout }); | |
| const ans = await rl2.question("Ti\u1EBFp t\u1EE5c? [y/N] "); | |
| rl2.close(); | |
| if (!/^y(es)?$/i.test(ans.trim())) { | |
| info("\u0110\xE3 hu\u1EF7."); | |
| return; | |
| } | |
| } | |
| const before = snapshotsIn(workDir); | |
| runCmd("kill", ["-USR2", String(m.mainPid)]); | |
| let file; | |
| for (let i = 0; i < 60 && !file; i++) { | |
| sleepMs(1e3); | |
| file = [...snapshotsIn(workDir)].find((f) => !before.has(f)); | |
| } | |
| if (!file) { | |
| die(`Sau 60s v\u1EABn kh\xF4ng th\u1EA5y file .heapsnapshot n\xE0o trong ${workDir}. Ki\u1EC3m tra ti\u1EBFn tr\xECnh c\xF2n s\u1ED1ng kh\xF4ng: systemctl status ${m.unit}`); | |
| } | |
| const full = `${workDir}/${file}`; | |
| let prev = -1; | |
| let stable = 0; | |
| for (let i = 0; i < 900 && stable < 3; i++) { | |
| sleepMs(1e3); | |
| const cur = (0, import_node_fs24.statSync)(full).size; | |
| stable = cur === prev && cur > 0 ? stable + 1 : 0; | |
| prev = cur; | |
| } | |
| if (stable < 3) warn("K\xEDch th\u01B0\u1EDBc file v\u1EABn \u0111ang thay \u0111\u1ED5i sau 15 ph\xFAt \u2014 c\xF3 th\u1EC3 ch\u01B0a ghi xong, h\xE3y ki\u1EC3m tra l\u1EA1i tr\u01B0\u1EDBc khi ph\xE2n t\xEDch."); | |
| ensureDir(HEAPSNAP_DIR, 448); | |
| const dest = `${HEAPSNAP_DIR}/${ref.id}-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-")}.heapsnapshot`; | |
| runCmd("mv", [full, dest]); | |
| runCmd("chmod", ["600", dest]); | |
| const sizeMB = Math.round((0, import_node_fs24.statSync)(dest).size / 1048576); | |
| ok(`\u0110\xE3 ch\u1EE5p xong: ${dest} (${sizeMB} MB)`); | |
| info(`Ti\u1EBFn tr\xECnh V\u1EAAN CH\u1EA0Y (snapshot kh\xF4ng l\xE0m app ch\u1EBFt).`); | |
| console.log(); | |
| info("Ph\xE2n t\xEDch:"); | |
| info(` 1. T\u1EA3i v\u1EC1 m\xE1y: scp <server>:${dest} .`); | |
| info(` 2. Chrome > F12 > tab Memory > Load > ch\u1ECDn file`); | |
| info(` 3. Ch\u1EE5p L\u1EA6N HAI sau v\xE0i gi\u1EDD, load c\u1EA3 hai, ch\u1ECDn 'Comparison' \u2014 th\u1EE9 T\u0102NG gi\u1EEFa hai l\u1EA7n ch\xEDnh l\xE0 ch\u1ED7 r\xF2 r\u1EC9.`); | |
| warn(`Nh\u1EDB xo\xE1 file khi xong: rm ${dest} (${sizeMB} MB)`); | |
| } | |
| function cmdMemGuard(id, on) { | |
| requireRoot(); | |
| const st = loadState(); | |
| const app2 = st.apps[id]; | |
| const svc = st.services[id]; | |
| if (!app2 && !svc) die(`Kh\xF4ng t\xECm th\u1EA5y app/service '${id}'.`); | |
| const runtime = (app2 ?? svc).nodeRuntime; | |
| if (runtime !== "node") { | |
| die( | |
| `'${id}' ch\u1EA1y b\u1EB1ng ${runtime}, kh\xF4ng ph\u1EA3i node. Hai c\u1EDD n\xE0y l\xE0 c\u1EE7a V8 \u2014 bun d\xF9ng JavaScriptCore n\xEAn kh\xF4ng hi\u1EC3u. | |
| V\u1EDBi bun, h\xE3y theo d\xF5i b\u1EB1ng 'napp mem trend' (\u0111o \u1EDF t\u1EA7ng cgroup n\xEAn runtime n\xE0o c\u0169ng \u0111\u01B0\u1EE3c).` | |
| ); | |
| } | |
| if (((app2 ?? svc).leakGuard ?? false) === on) { | |
| info(`'${id}' \u0111\xE3 \u1EDF \u0111\xFAng tr\u1EA1ng th\xE1i (leak-guard ${on ? "B\u1EACT" : "T\u1EAET"}) \u2014 kh\xF4ng c\xF3 g\xEC \u0111\u1EC3 \u0111\u1ED5i.`); | |
| return; | |
| } | |
| const plan = currentHeapPlan(); | |
| const unit = app2 ? `napp-${app2.domain}` : `napp-svc-${svc.name}`; | |
| if (app2) { | |
| app2.leakGuard = on; | |
| app2.updatedAt = (/* @__PURE__ */ new Date()).toISOString(); | |
| upsertApp(app2); | |
| writeAppUnit(app2, plan.webMB); | |
| } else { | |
| svc.leakGuard = on; | |
| svc.updatedAt = (/* @__PURE__ */ new Date()).toISOString(); | |
| upsertService(svc); | |
| writeServiceUnit(svc, plan.serviceMB); | |
| } | |
| runCmd("systemctl", ["daemon-reload"]); | |
| runCmd("systemctl", ["restart", unit], { silentFail: true }); | |
| if (!on) { | |
| ok(`\u0110\xE3 T\u1EAET leak-guard cho '${id}' v\xE0 kh\u1EDFi \u0111\u1ED9ng l\u1EA1i.`); | |
| return; | |
| } | |
| ok(`\u0110\xE3 B\u1EACT leak-guard cho '${id}' v\xE0 kh\u1EDFi \u0111\u1ED9ng l\u1EA1i.`); | |
| info(`\u2022 --heapsnapshot-near-heap-limit=1 : Node T\u1EF0 ch\u1EE5p heap ngay tr\u01B0\u1EDBc khi ch\u1EBFt v\xEC OOM, thay v\xEC ch\u1EBFt kh\xF4ng \u0111\u1EC3 l\u1EA1i g\xEC.`); | |
| info(`\u2022 --heapsnapshot-signal=SIGUSR2 : ch\u1EE5p theo y\xEAu c\u1EA7u b\u1EB1ng 'napp mem snapshot ${id}' (app v\u1EABn ch\u1EA1y).`); | |
| warn( | |
| `File snapshot l\u1EDBn kho\u1EA3ng G\u1EA4P \u0110\xD4I heap v\xE0 Node lu\xF4n ghi v\xE0o TH\u01AF M\u1EE4C L\xC0M VI\u1EC6C c\u1EE7a app (kh\xF4ng \u0111\u1ED5i \u0111\u01B0\u1EE3c ch\u1ED7). | |
| App r\xF2 r\u1EC9 t\u1EDBi tr\u1EA7n 2 GB s\u1EBD \u0111\u1EC3 l\u1EA1i m\u1ED9t file ~4 GB ngay trong c\xE2y m\xE3 ngu\u1ED3n, v\xE0 m\u1EA5t V\xC0I PH\xDAT \u0111\u1EC3 ghi | |
| (\u0111o th\u1EF1c t\u1EBF: heap 96 MB m\u1EA5t 176 gi\xE2y). Theo d\xF5i ch\u1ED7 tr\u1ED1ng \u2014 'napp mem status' s\u1EBD b\xE1o khi th\u1EA5y file s\xF3t l\u1EA1i.` | |
| ); | |
| info( | |
| `L\u01B0u \xFD: '--heapsnapshot-near-heap-limit' ch\u1EE5p khi S\u1EAEP ch\u1EA1m tr\u1EA7n, v\xE0 app th\u01B0\u1EDDng V\u1EAAN CH\u1EA0Y TI\u1EBEP sau \u0111\xF3 (V8 gom r\xE1c r\u1ED3i \u0111i ti\u1EBFp) \u2014 n\xEAn c\xF3 file snapshot kh\xF4ng \u0111\u1ED3ng ngh\u0129a app \u0111\xE3 ch\u1EBFt.` | |
| ); | |
| } | |
| // src/commands/cloudflare.ts | |
| var NAPP_BIN_PATH3 = "/usr/local/bin/napp"; | |
| var CF_TIMER_NAME = "napp-cloudflare-sync"; | |
| async function cmdCloudflareSync(opts = {}) { | |
| requireRoot(); | |
| if (!commandExists("nginx")) die("nginx ch\u01B0a \u0111\u01B0\u1EE3c c\xE0i. Ch\u1EA1y 'napp check --fix' tr\u01B0\u1EDBc."); | |
| const log = opts.quiet ? () => { | |
| } : info; | |
| log("\u0110ang t\u1EA3i d\u1EA3i IP Cloudflare hi\u1EC7n t\u1EA1i..."); | |
| const ranges = await fetchCloudflareIpRanges(); | |
| writeFile(CLOUDFLARE_REALIP_CONF, renderCloudflareRealIpSnippet(ranges.ipv4, ranges.ipv6), 420); | |
| const nginxConf = execCapture("bash", ["-lc", "grep -c 'include /etc/nginx/conf.d' /etc/nginx/nginx.conf || true"]).stdout.trim(); | |
| if (nginxConf === "0") { | |
| console.log( | |
| "[C\u1EA2NH B\xC1O] /etc/nginx/nginx.conf c\xF3 v\u1EBB ch\u01B0a include /etc/nginx/conf.d/*.conf trong kh\u1ED1i http {}. H\xE3y th\xEAm d\xF2ng `include /etc/nginx/conf.d/*.conf;` th\u1EE7 c\xF4ng r\u1ED3i ch\u1EA1y l\u1EA1i." | |
| ); | |
| } | |
| const test = execCapture("nginx", ["-t"]); | |
| if (test.code !== 0) die(`Ki\u1EC3m tra c\u1EA5u h\xECnh nginx th\u1EA5t b\u1EA1i: | |
| ${test.stderr}`); | |
| runCmd("systemctl", ["reload", "nginx"]); | |
| ok(`\u0110\xE3 \u0111\u1ED3ng b\u1ED9 ${ranges.ipv4.length + ranges.ipv6.length} d\u1EA3i IP Cloudflare v\xE0o ${CLOUDFLARE_REALIP_CONF} v\xE0 reload nginx.`); | |
| } | |
| function cmdCloudflareSchedule(opts) { | |
| requireRoot(); | |
| if (!commandExists("nginx")) die("nginx ch\u01B0a \u0111\u01B0\u1EE3c c\xE0i. Ch\u1EA1y 'napp check --fix' tr\u01B0\u1EDBc."); | |
| const onCalendar = timeToDailyOnCalendar(opts.time); | |
| writeManagedUnit(`${SYSTEMD_DIR}/${CF_TIMER_NAME}.service`, renderCloudflareSyncService(NAPP_BIN_PATH3), { authoritative: ["ExecStart"] }); | |
| writeFile(`${SYSTEMD_DIR}/${CF_TIMER_NAME}.timer`, renderCloudflareSyncTimer(onCalendar), 420); | |
| runCmd("systemctl", ["daemon-reload"]); | |
| runCmd("systemctl", ["enable", "--now", `${CF_TIMER_NAME}.timer`]); | |
| ok(`\u0110\xE3 l\xEAn l\u1ECBch t\u1EF1 \u0111\u1ED9ng \u0111\u1ED3ng b\u1ED9 IP Cloudflare h\xE0ng ng\xE0y l\xFAc ${opts.time} v\xE0o nginx real-IP.`); | |
| info(`Ki\u1EC3m tra l\u1ECBch ch\u1EA1y: systemctl list-timers ${CF_TIMER_NAME}.timer`); | |
| } | |
| function cmdCloudflareUnschedule() { | |
| requireRoot(); | |
| runCmd("systemctl", ["disable", "--now", `${CF_TIMER_NAME}.timer`], { silentFail: true }); | |
| runCmd("rm", ["-f", `${SYSTEMD_DIR}/${CF_TIMER_NAME}.service`, `${SYSTEMD_DIR}/${CF_TIMER_NAME}.timer`], { silentFail: true }); | |
| runCmd("systemctl", ["daemon-reload"]); | |
| ok("\u0110\xE3 g\u1EE1 l\u1ECBch t\u1EF1 \u0111\u1ED9ng \u0111\u1ED3ng b\u1ED9 IP Cloudflare."); | |
| } | |
| // src/commands/update.ts | |
| var import_node_fs26 = require("node:fs"); | |
| var import_node_os2 = require("node:os"); | |
| var import_node_path2 = require("node:path"); | |
| // src/commands/installSelf.ts | |
| var import_node_fs25 = require("node:fs"); | |
| var INSTALL_PATH = "/usr/local/bin/napp"; | |
| var MOTD_PATH = "/etc/update-motd.d/99-napp"; | |
| function motdScript() { | |
| return `#!/bin/bash | |
| # Managed by napp \u2014 banner gi\u1EDBi thi\u1EC7u khi \u0111\u0103ng nh\u1EADp SSH. | |
| # T\u1EF0 SINH b\u1EDFi 'napp install'; g\u1EE1 b\u1EB1ng 'napp uninstall'. \u0110\u1EEANG s\u1EEDa tay. | |
| command -v napp >/dev/null 2>&1 || exit 0 | |
| ver="$(napp version 2>/dev/null | awk '{print $NF}')" | |
| [ -n "$ver" ] && ver="v$ver" | |
| if [ -n "\${NO_COLOR:-}" ]; then | |
| c=""; b=""; d=""; r="" | |
| else | |
| esc="$(printf '\\033')" | |
| c="\${esc}[36m"; b="\${esc}[1m"; d="\${esc}[2m"; r="\${esc}[0m" | |
| fi | |
| cat <<BANNER | |
| \${c}\${b}\u2588\u2588\u2588\u2557 \u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2588\u2588\u2588\u2588\u2557\${r} \${d}\${ver}\${r} | |
| \${c}\${b}\u2588\u2588\u2588\u2588\u2557 \u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2557\${r} | |
| \${c}\${b}\u2588\u2588\u2554\u2588\u2588\u2557 \u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2588\u2551 \u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D \u2588\u2588\u2588\u2588\u2588\u2588\u2554\u255D\${r} Qu\u1EA3n l\xFD server Node.js/Bun \u0111a \u1EE9ng d\u1EE5ng | |
| \${c}\${b}\u2588\u2588\u2551\u255A\u2588\u2588\u2557\u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2588\u2588\u2551 \u2588\u2588\u2554\u2550\u2550\u2550\u255D \u2588\u2588\u2554\u2550\u2550\u2550\u255D\${r} | |
| \${c}\${b}\u2588\u2588\u2551 \u255A\u2588\u2588\u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551 \u2588\u2588\u2551\${r} | |
| \${c}\${b}\u255A\u2550\u255D \u255A\u2550\u2550\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D \u255A\u2550\u255D\${r} | |
| \${b}B\u1EAFt \u0111\u1EA7u\${r} sudo napp \${d}# m\u1EDF menu t\u01B0\u01A1ng t\xE1c\${r} | |
| \${b}\u1EE8ng d\u1EE5ng\${r} sudo napp app list \${d}# xem / t\u1EA1o / deploy app\${r} | |
| \${b}M\xF4i tr\u01B0\u1EDDng\${r} sudo napp check --fix \${d}# ki\u1EC3m tra & t\u1EF1 c\xE0i ph\u1EE5 thu\u1ED9c\${r} | |
| \${d}C\u1EADp nh\u1EADt: sudo napp update \xB7 G\u1EE1 banner: sudo napp uninstall\${r} | |
| BANNER | |
| `; | |
| } | |
| function writeMotdBanner() { | |
| writeFile(MOTD_PATH, motdScript(), 493); | |
| } | |
| function cmdInstallSelf() { | |
| requireRoot(); | |
| let src; | |
| try { | |
| src = (0, import_node_fs25.realpathSync)(process.argv[1] ?? ""); | |
| } catch { | |
| src = process.argv[1] ?? ""; | |
| } | |
| if (!(0, import_node_fs25.existsSync)(src)) { | |
| warn(`Kh\xF4ng x\xE1c \u0111\u1ECBnh \u0111\u01B0\u1EE3c v\u1ECB tr\xED file th\u1EF1c thi hi\u1EC7n t\u1EA1i (${src}) \u2014 h\xE3y ch\u1EA1y 'install' tr\u1EF1c ti\u1EBFp t\u1EEB file napp.cjs \u0111\xE3 t\u1EA3i v\u1EC1.`); | |
| return; | |
| } | |
| if (src === INSTALL_PATH) { | |
| ok(`napp \u0111\xE3 \u0111\u01B0\u1EE3c c\xE0i s\u1EB5n t\u1EA1i ${INSTALL_PATH}`); | |
| } else { | |
| ensureDir("/usr/local/bin", 493); | |
| (0, import_node_fs25.copyFileSync)(src, INSTALL_PATH); | |
| (0, import_node_fs25.chmodSync)(INSTALL_PATH, 493); | |
| ok(`\u0110\xE3 c\xE0i v\xE0o ${INSTALL_PATH}`); | |
| } | |
| writeMotdBanner(); | |
| ok("\u0110\xE3 c\xE0i banner gi\u1EDBi thi\u1EC7u SSH (hi\u1EC7n m\u1ED7i l\u1EA7n \u0111\u0103ng nh\u1EADp)."); | |
| info("Gi\u1EDD b\u1EA1n c\xF3 th\u1EC3 ch\u1EA1y napp t\u1EEB b\u1EA5t c\u1EE9 \u0111\xE2u, v\xED d\u1EE5: sudo napp check --fix"); | |
| } | |
| function cmdUninstallSelf() { | |
| requireRoot(); | |
| let removed = false; | |
| if ((0, import_node_fs25.existsSync)(INSTALL_PATH)) { | |
| (0, import_node_fs25.unlinkSync)(INSTALL_PATH); | |
| ok(`\u0110\xE3 g\u1EE1 ${INSTALL_PATH} (c\xE1c app, systemd service v\xE0 c\u1EA5u h\xECnh nginx hi\u1EC7n c\xF3 v\u1EABn gi\u1EEF nguy\xEAn)`); | |
| removed = true; | |
| } | |
| if ((0, import_node_fs25.existsSync)(MOTD_PATH)) { | |
| (0, import_node_fs25.unlinkSync)(MOTD_PATH); | |
| ok(`\u0110\xE3 g\u1EE1 banner ch\xE0o m\u1EEBng SSH (${MOTD_PATH})`); | |
| removed = true; | |
| } | |
| if (!removed) warn(`Kh\xF4ng c\xF3 g\xEC \u0111\u1EC3 g\u1EE1 \u2014 ${INSTALL_PATH} kh\xF4ng t\u1ED3n t\u1EA1i.`); | |
| } | |
| // src/commands/update.ts | |
| var INSTALL_PATH2 = "/usr/local/bin/napp"; | |
| function cmdVersion() { | |
| console.log(`napp version ${NAPP_VERSION}`); | |
| } | |
| function cmdChangelog() { | |
| console.log(CHANGELOG); | |
| } | |
| async function cmdUpdate() { | |
| requireRoot(); | |
| const url = process.env.NAPP_UPDATE_URL ?? NAPP_UPDATE_URL_DEFAULT; | |
| if (!url || url.includes("REPLACE_WITH_GIST_ID")) { | |
| die( | |
| `Ch\u01B0a c\u1EA5u h\xECnh ngu\u1ED3n c\u1EADp nh\u1EADt. | |
| H\xE3y t\u1EA1o gist c\xF4ng khai ch\u1EE9a napp.cjs, r\u1ED3i \u0111\u1EB7t URL raw 'm\u1EDBi nh\u1EA5t' c\u1EE7a n\xF3 v\xE0o | |
| NAPP_UPDATE_URL_DEFAULT trong src/version.ts r\u1ED3i build l\u1EA1i, ho\u1EB7c ghi \u0111\xE8 l\xFAc ch\u1EA1y: | |
| sudo NAPP_UPDATE_URL="https://gist.githubusercontent.com/<user>/<id>/raw/napp.cjs" napp update` | |
| ); | |
| } | |
| if (!url.startsWith("https://")) die(`URL c\u1EADp nh\u1EADt ph\u1EA3i d\xF9ng HTTPS: ${url}`); | |
| info(`\u0110ang t\u1EA3i b\u1EA3n m\u1EDBi nh\u1EA5t t\u1EEB: ${url}`); | |
| const res = await fetch(url, { signal: AbortSignal.timeout(3e4) }).catch((e) => { | |
| die(`T\u1EA3i th\u1EA5t b\u1EA1i \u2014 ki\u1EC3m tra URL ho\u1EB7c k\u1EBFt n\u1ED1i m\u1EA1ng: ${e.message}`); | |
| }); | |
| if (!res || !res.ok) die(`T\u1EA3i th\u1EA5t b\u1EA1i \u2014 HTTP ${res?.status ?? "?"}`); | |
| const content = await res.text(); | |
| const tmpDir = (0, import_node_fs26.mkdtempSync)((0, import_node_path2.join)((0, import_node_os2.tmpdir)(), "napp-update-")); | |
| const tmpPath = (0, import_node_path2.join)(tmpDir, "napp.cjs"); | |
| (0, import_node_fs26.writeFileSync)(tmpPath, content, "utf8"); | |
| const syntaxCheck = execCapture("node", ["--check", tmpPath]); | |
| if (syntaxCheck.code !== 0) { | |
| (0, import_node_fs26.unlinkSync)(tmpPath); | |
| die(`File t\u1EA3i v\u1EC1 l\u1ED7i c\xFA ph\xE1p \u2014 KH\xD4NG c\xE0i (c\xF3 th\u1EC3 t\u1EA3i d\u1EDF/h\u1ECFng): | |
| ${syntaxCheck.stderr}`); | |
| } | |
| if (!content.includes("__NAPP_MARKER__")) { | |
| (0, import_node_fs26.unlinkSync)(tmpPath); | |
| die("File t\u1EA3i v\u1EC1 kh\xF4ng gi\u1ED1ng napp.cjs (thi\u1EBFu marker) \u2014 KH\xD4NG c\xE0i."); | |
| } | |
| const versionMatch = content.match(/__NAPP_MARKER__ version=(\S+)/); | |
| const newVersion = versionMatch?.[1] ?? "?"; | |
| info(`B\u1EA3n \u0111ang ch\u1EA1y: ${NAPP_VERSION} -> b\u1EA3n t\u1EA3i v\u1EC1: ${newVersion}`); | |
| if (newVersion === NAPP_VERSION) { | |
| warn(`\u0110\xE3 l\xE0 b\u1EA3n m\u1EDBi nh\u1EA5t (${NAPP_VERSION}); v\u1EABn c\xE0i l\u1EA1i cho ch\u1EAFc.`); | |
| } | |
| (0, import_node_fs26.chmodSync)(tmpPath, 493); | |
| runCmd("install", ["-m", "0755", tmpPath, INSTALL_PATH2]); | |
| (0, import_node_fs26.unlinkSync)(tmpPath); | |
| ok(`\u0110\xE3 c\u1EADp nh\u1EADt napp: ${NAPP_VERSION} -> ${newVersion} (${INSTALL_PATH2})`); | |
| if ((0, import_node_fs26.existsSync)(MOTD_PATH)) { | |
| writeMotdBanner(); | |
| info("\u0110\xE3 l\xE0m m\u1EDBi banner gi\u1EDBi thi\u1EC7u SSH."); | |
| } | |
| } | |
| // src/commands/menu.ts | |
| var import_promises = __toESM(require("node:readline/promises")); | |
| var rl; | |
| async function ask(q) { | |
| return (await rl.question(q)).trim(); | |
| } | |
| async function askYesNo(q, def = false) { | |
| const ans = await ask(`${q} [${def ? "Y/n" : "y/N"}] `); | |
| if (!ans) return def; | |
| return /^y(es)?$/i.test(ans); | |
| } | |
| async function askSshKey() { | |
| const first = (await rl.question("Deploy key \u2014 D\xC1N n\u1ED9i dung key (b\u1EAFt \u0111\u1EA7u '-----BEGIN'), ho\u1EB7c nh\u1EADp \u0110\u01AF\u1EDCNG D\u1EAAN file:\n")).trim(); | |
| if (!first) return void 0; | |
| if (!/^-----BEGIN /.test(first)) return first; | |
| const lines = [first]; | |
| while (!/-----END [A-Z0-9 ]*PRIVATE KEY-----/.test(lines[lines.length - 1])) { | |
| const line = await rl.question(""); | |
| lines.push(line.replace(/\r$/, "")); | |
| } | |
| return lines.join("\n"); | |
| } | |
| async function askChoice(label, options, defaultValue) { | |
| console.log(`${label}:`); | |
| options.forEach((o, i) => console.log(` ${i + 1}. ${o}${o === defaultValue ? " (m\u1EB7c \u0111\u1ECBnh)" : ""}`)); | |
| const ans = await ask(`Ch\u1ECDn [1-${options.length}] (Enter = ${defaultValue}): `); | |
| if (!ans) return defaultValue; | |
| const n = parseInt(ans, 10); | |
| if (Number.isInteger(n) && n >= 1 && n <= options.length) { | |
| const picked = options[n - 1]; | |
| if (picked !== void 0) return picked; | |
| } | |
| const byName = options.find((o) => o.toLowerCase() === ans.toLowerCase()); | |
| if (byName) return byName; | |
| warn(`L\u1EF1a ch\u1ECDn kh\xF4ng h\u1EE3p l\u1EC7 '${ans}' \u2014 d\xF9ng m\u1EB7c \u0111\u1ECBnh '${defaultValue}'.`); | |
| return defaultValue; | |
| } | |
| async function askAppDomain(actionLabel) { | |
| const apps = listAppSummaries(); | |
| if (apps.length === 0) { | |
| warn("Ch\u01B0a c\xF3 app n\xE0o \u0111\u01B0\u1EE3c napp qu\u1EA3n l\xFD \u2014 h\xE3y t\u1EA1o app tr\u01B0\u1EDBc (m\u1EE5c 'T\u1EA1o app m\u1EDBi')."); | |
| return void 0; | |
| } | |
| console.log(`Ch\u1ECDn app \u0111\u1EC3 ${actionLabel}:`); | |
| apps.forEach( | |
| (a, i) => console.log(` ${i + 1}. ${a.domain.padEnd(30)} port=${a.port} ${a.running ? "\u25CF \u0111ang ch\u1EA1y" : "\u25CB \u0111\xE3 d\u1EEBng"}`) | |
| ); | |
| const ans = await ask(`Ch\u1ECDn [1-${apps.length}] (0 = hu\u1EF7): `); | |
| if (!ans || ans === "0") return void 0; | |
| const n = parseInt(ans, 10); | |
| if (Number.isInteger(n) && n >= 1 && n <= apps.length) { | |
| const picked = apps[n - 1]; | |
| if (picked) return picked.domain; | |
| } | |
| const byName = apps.find((a) => a.domain === ans.trim()); | |
| if (byName) return byName.domain; | |
| warn(`L\u1EF1a ch\u1ECDn kh\xF4ng h\u1EE3p l\u1EC7: '${ans}'.`); | |
| return void 0; | |
| } | |
| async function askServiceName(actionLabel) { | |
| const services = listServiceSummaries(); | |
| if (services.length === 0) { | |
| warn("Ch\u01B0a c\xF3 background service n\xE0o \u2014 h\xE3y t\u1EA1o service tr\u01B0\u1EDBc (m\u1EE5c 'T\u1EA1o service m\u1EDBi')."); | |
| return void 0; | |
| } | |
| console.log(`Ch\u1ECDn service \u0111\u1EC3 ${actionLabel}:`); | |
| services.forEach( | |
| (s, i) => console.log(` ${i + 1}. ${s.name.padEnd(30)} ${s.port !== void 0 ? `port=${s.port}` : "no-port"} ${s.running ? "\u25CF \u0111ang ch\u1EA1y" : "\u25CB \u0111\xE3 d\u1EEBng"}`) | |
| ); | |
| const ans = await ask(`Ch\u1ECDn [1-${services.length}] (0 = hu\u1EF7): `); | |
| if (!ans || ans === "0") return void 0; | |
| const n = parseInt(ans, 10); | |
| if (Number.isInteger(n) && n >= 1 && n <= services.length) { | |
| const picked = services[n - 1]; | |
| if (picked) return picked.name; | |
| } | |
| const byName = services.find((s) => s.name === ans.trim()); | |
| if (byName) return byName.name; | |
| warn(`L\u1EF1a ch\u1ECDn kh\xF4ng h\u1EE3p l\u1EC7: '${ans}'.`); | |
| return void 0; | |
| } | |
| async function askDatabase(actionLabel) { | |
| const dbs = listDatabases(); | |
| if (dbs.length === 0) { | |
| warn("Kh\xF4ng t\xECm th\u1EA5y database n\xE0o (ho\u1EB7c MariaDB ch\u01B0a ch\u1EA1y / ch\u01B0a k\u1EBFt n\u1ED1i \u0111\u01B0\u1EE3c)."); | |
| return void 0; | |
| } | |
| console.log(`Ch\u1ECDn database \u0111\u1EC3 ${actionLabel}:`); | |
| dbs.forEach((d, i) => console.log(` ${i + 1}. ${d}`)); | |
| console.log(` a. T\u1EA4T C\u1EA2 database`); | |
| const ans = (await ask(`Ch\u1ECDn [1-${dbs.length} / a = t\u1EA5t c\u1EA3] (0 = hu\u1EF7): `)).trim(); | |
| if (!ans || ans === "0") return void 0; | |
| if (ans.toLowerCase() === "a") return "__ALL__"; | |
| const n = parseInt(ans, 10); | |
| if (Number.isInteger(n) && n >= 1 && n <= dbs.length) return dbs[n - 1]; | |
| const byName = dbs.find((d) => d === ans); | |
| if (byName) return byName; | |
| warn(`L\u1EF1a ch\u1ECDn kh\xF4ng h\u1EE3p l\u1EC7: '${ans}'.`); | |
| return void 0; | |
| } | |
| async function askMultiSelect(label, options) { | |
| const selected = new Set(options.filter((o) => o.default).map((o) => o.key)); | |
| while (true) { | |
| console.log(`${label}`); | |
| options.forEach((o, i) => console.log(` ${i + 1}. [${selected.has(o.key) ? "x" : " "}] ${o.label}`)); | |
| const ans = await ask(`G\xF5 s\u1ED1 \u0111\u1EC3 b\u1EADt/t\u1EAFt (c\xE1ch nhau b\u1EDFi d\u1EA5u c\xE1ch/ph\u1EA9y), Enter = x\xE1c nh\u1EADn: `); | |
| if (!ans) return selected; | |
| for (const tok of ans.split(/[\s,]+/).filter(Boolean)) { | |
| const n = parseInt(tok, 10); | |
| if (Number.isInteger(n) && n >= 1 && n <= options.length) { | |
| const key = options[n - 1].key; | |
| if (selected.has(key)) selected.delete(key); | |
| else selected.add(key); | |
| } else { | |
| warn(`B\u1ECF qua l\u1EF1a ch\u1ECDn kh\xF4ng h\u1EE3p l\u1EC7: '${tok}'`); | |
| } | |
| } | |
| console.log(); | |
| } | |
| } | |
| async function askRetentionDays() { | |
| const ans = (await ask(`Gi\u1EEF backup trong bao nhi\xEAu NG\xC0Y (retention, m\u1EB7c \u0111\u1ECBnh ${DEFAULT_RETENTION_DAYS}): `)).trim(); | |
| const n = parseInt(ans, 10); | |
| return Number.isInteger(n) && n > 0 ? n : DEFAULT_RETENTION_DAYS; | |
| } | |
| function printMenu(title, items) { | |
| console.clear(); | |
| section(title); | |
| items.forEach((label, i) => console.log(` ${i + 1}. ${label}`)); | |
| console.log(` 0. Quay l\u1EA1i / Tho\xE1t`); | |
| console.log(); | |
| } | |
| async function pause() { | |
| await ask("\nNh\u1EA5n Enter \u0111\u1EC3 ti\u1EBFp t\u1EE5c..."); | |
| } | |
| async function guard(fn) { | |
| try { | |
| await fn(); | |
| } catch (e) { | |
| warn(e.message); | |
| } | |
| await pause(); | |
| } | |
| async function menuApp() { | |
| while (true) { | |
| printMenu("Qu\u1EA3n l\xFD App Node.js/Bun", [ | |
| "Danh s\xE1ch app", | |
| "T\u1EA1o app m\u1EDBi", | |
| "Deploy (git pull + rebuild + restart)", | |
| "Restart app", | |
| "Xem log (tail 100 d\xF2ng)", | |
| "B\u1EADt nginx tr\u1EA3 asset t\u0129nh (t\u1EF1 nh\u1EADn di\u1EC7n framework)", | |
| "Xo\xE1 app" | |
| ]); | |
| const choice = await ask("Ch\u1ECDn: "); | |
| if (choice === "0" || choice === "") return; | |
| if (choice === "1") { | |
| await guard(() => cmdAppList()); | |
| } else if (choice === "2") { | |
| await guard(async () => { | |
| const domain2 = await ask("Domain (vd: api.example.com): "); | |
| const repo = await ask("Git repo URL (b\u1ECF tr\u1ED1ng n\u1EBFu ch\u01B0a c\xF3): "); | |
| let token; | |
| let sshKey; | |
| if (repo && await askYesNo("Repo n\xE0y c\xF3 PRIVATE (c\u1EA7n x\xE1c th\u1EF1c) kh\xF4ng?")) { | |
| if (/^https?:\/\//i.test(repo)) { | |
| token = (await ask("Personal Access Token (HTTPS): ")).trim() || void 0; | |
| } else { | |
| sshKey = await askSshKey(); | |
| } | |
| } | |
| const runtime = await askChoice("Runtime engine", ["node", "bun"], "node"); | |
| const pmDefault = runtime === "bun" ? "bun" : "npm"; | |
| const packageManager = await askChoice("Tr\xECnh qu\u1EA3n l\xFD g\xF3i ph\u1EE5 thu\u1ED9c", ["npm", "pnpm", "yarn", "bun"], pmDefault); | |
| const db2 = await askYesNo("T\u1EA1o database MariaDB ri\xEAng cho app n\xE0y?"); | |
| const redis2 = await askYesNo("C\u1EA5p Redis DB ri\xEAng cho app n\xE0y?"); | |
| const autoStatic = await askYesNo("Cho nginx tr\u1EA3 th\u1EB3ng asset t\u0129nh n\u1EBFu nh\u1EADn di\u1EC7n \u0111\u01B0\u1EE3c framework (nhanh h\u01A1n nhi\u1EC1u)?"); | |
| await cmdAppCreate(domain2, { | |
| repo: repo || void 0, | |
| branch: "main", | |
| token, | |
| sshKey, | |
| runtime, | |
| packageManager, | |
| db: db2, | |
| redis: redis2, | |
| autoStatic, | |
| env: [] | |
| }); | |
| }); | |
| } else if (choice === "3") { | |
| await guard(async () => { | |
| const domain2 = await askAppDomain("deploy"); | |
| if (domain2) await cmdAppDeploy(domain2); | |
| }); | |
| } else if (choice === "4") { | |
| await guard(async () => { | |
| const domain2 = await askAppDomain("restart"); | |
| if (domain2) cmdAppRestart(domain2); | |
| }); | |
| } else if (choice === "5") { | |
| await guard(async () => { | |
| const domain2 = await askAppDomain("xem log"); | |
| if (domain2) cmdAppLogs(domain2, { follow: false, lines: 100 }); | |
| }); | |
| } else if (choice === "6") { | |
| await guard(async () => { | |
| const domain2 = await askAppDomain("b\u1EADt asset t\u0129nh"); | |
| if (domain2) cmdAppSet(domain2, { autoStatic: true }); | |
| }); | |
| } else if (choice === "7") { | |
| await guard(async () => { | |
| const domain2 = await askAppDomain("XO\xC1"); | |
| if (!domain2) return; | |
| const sel = await askMultiSelect(`Ch\u1ECDn nh\u1EEFng g\xEC c\u1EA7n xo\xE1 khi g\u1EE1 app '${domain2}' ([x] = s\u1EBD xo\xE1; service systemd lu\xF4n b\u1ECB g\u1EE1):`, [ | |
| { key: "nginx", label: "C\u1EA5u h\xECnh domain nginx", default: true }, | |
| { key: "ssl", label: "Ch\u1EE9ng ch\u1EC9 SSL", default: true }, | |
| { key: "source", label: "M\xE3 ngu\u1ED3n (v\xE0 user h\u1EC7 th\u1ED1ng)", default: false }, | |
| { key: "database", label: "Database", default: false } | |
| ]); | |
| const yes = await askYesNo(`X\xE1c nh\u1EADn g\u1EE1 app '${domain2}' (kh\xF4ng th\u1EC3 ho\xE0n t\xE1c)?`); | |
| if (yes) | |
| await cmdAppRemove(domain2, { | |
| yes: true, | |
| nginx: sel.has("nginx"), | |
| ssl: sel.has("ssl"), | |
| source: sel.has("source"), | |
| database: sel.has("database") | |
| }); | |
| }); | |
| } | |
| } | |
| } | |
| async function menuService() { | |
| while (true) { | |
| printMenu("Qu\u1EA3n l\xFD Background Service (ch\u1EA1y ng\u1EA7m, kh\xF4ng domain)", [ | |
| "Danh s\xE1ch service", | |
| "T\u1EA1o service m\u1EDBi", | |
| "Deploy (git pull + rebuild + restart)", | |
| "Restart service", | |
| "Xem log (tail 100 d\xF2ng)", | |
| "Xo\xE1 service" | |
| ]); | |
| const choice = await ask("Ch\u1ECDn: "); | |
| if (choice === "0" || choice === "") return; | |
| if (choice === "1") { | |
| await guard(() => cmdServiceList()); | |
| } else if (choice === "2") { | |
| await guard(async () => { | |
| const name = await ask("T\xEAn service (vd: worker-telegram, queue-email): "); | |
| const repo = await ask("Git repo URL (b\u1ECF tr\u1ED1ng n\u1EBFu ch\u01B0a c\xF3): "); | |
| let token; | |
| let sshKey; | |
| if (repo && await askYesNo("Repo n\xE0y c\xF3 PRIVATE (c\u1EA7n x\xE1c th\u1EF1c) kh\xF4ng?")) { | |
| if (/^https?:\/\//i.test(repo)) { | |
| token = (await ask("Personal Access Token (HTTPS): ")).trim() || void 0; | |
| } else { | |
| sshKey = await askSshKey(); | |
| } | |
| } | |
| const startCmd = (await ask("L\u1EC7nh kh\u1EDFi \u0111\u1ED9ng (Enter = 'npm start' theo package.json; vd: node worker.js): ")).trim() || void 0; | |
| const runtime = await askChoice("Runtime engine", ["node", "bun"], "node"); | |
| const pmDefault = runtime === "bun" ? "bun" : "npm"; | |
| const packageManager = await askChoice("Tr\xECnh qu\u1EA3n l\xFD g\xF3i ph\u1EE5 thu\u1ED9c", ["npm", "pnpm", "yarn", "bun"], pmDefault); | |
| const wantPort = await askYesNo("Service c\xF3 t\u1EF1 listen m\u1ED9t c\u1ED5ng n\u1ED9i b\u1ED9 kh\xF4ng (health-check/socket)?"); | |
| let port; | |
| if (wantPort) { | |
| const p = parseInt((await ask("C\u1ED5ng n\u1ED9i b\u1ED9 (Enter = t\u1EF1 c\u1EA5p 3000-3999): ")).trim(), 10); | |
| if (Number.isInteger(p)) port = p; | |
| } | |
| let runAs2; | |
| if (await askYesNo("Worker n\xE0y c\xF3 \u0111\u1ECDc/ghi FILE c\u1EE7a m\u1ED9t app web \u0111\xE3 c\xF3 kh\xF4ng (n\xE9n \u1EA3nh, thumbnail, d\u1ECDn cache)?")) { | |
| runAs2 = await askAppDomain("ch\u1EA1y chung user h\u1EC7 th\u1ED1ng (worker s\u1EBD ghi \u0111\u01B0\u1EE3c v\xE0o th\u01B0 m\u1EE5c c\u1EE7a app n\xE0y)"); | |
| } | |
| const db2 = await askYesNo("T\u1EA1o database MariaDB ri\xEAng cho service n\xE0y?"); | |
| const redis2 = await askYesNo("C\u1EA5p Redis DB ri\xEAng cho service n\xE0y?"); | |
| const sharedRedis = runAs2 ? findUnit(runAs2)?.redisDbIndex : void 0; | |
| let shareRedisWith; | |
| if (redis2 && runAs2 && sharedRedis !== void 0) { | |
| if (await askYesNo(`D\xF9ng CHUNG Redis DB #${sharedRedis} v\u1EDBi '${runAs2}' (B\u1EAET BU\u1ED8C n\u1EBFu worker ti\xEAu th\u1EE5 h\xE0ng \u0111\u1EE3i c\u1EE7a app \u0111\xF3)?`, true)) { | |
| shareRedisWith = runAs2; | |
| } | |
| } | |
| await cmdServiceCreate(name, { | |
| repo: repo || void 0, | |
| branch: "main", | |
| token, | |
| sshKey, | |
| startCmd, | |
| runtime, | |
| packageManager, | |
| port, | |
| db: db2, | |
| redis: redis2, | |
| shareRedisWith, | |
| runAs: runAs2, | |
| writeDirs: [], | |
| env: [] | |
| }); | |
| }); | |
| } else if (choice === "3") { | |
| await guard(async () => { | |
| const name = await askServiceName("deploy"); | |
| if (name) await cmdServiceDeploy(name); | |
| }); | |
| } else if (choice === "4") { | |
| await guard(async () => { | |
| const name = await askServiceName("restart"); | |
| if (name) cmdServiceRestart(name); | |
| }); | |
| } else if (choice === "5") { | |
| await guard(async () => { | |
| const name = await askServiceName("xem log"); | |
| if (name) cmdServiceLogs(name, { follow: false, lines: 100 }); | |
| }); | |
| } else if (choice === "6") { | |
| await guard(async () => { | |
| const name = await askServiceName("XO\xC1"); | |
| if (!name) return; | |
| const sel = await askMultiSelect(`Ch\u1ECDn nh\u1EEFng g\xEC c\u1EA7n xo\xE1 khi g\u1EE1 service '${name}' ([x] = s\u1EBD xo\xE1; service systemd lu\xF4n b\u1ECB g\u1EE1):`, [ | |
| { key: "source", label: "M\xE3 ngu\u1ED3n (v\xE0 user h\u1EC7 th\u1ED1ng)", default: false }, | |
| { key: "database", label: "Database", default: false } | |
| ]); | |
| const yes = await askYesNo(`X\xE1c nh\u1EADn g\u1EE1 service '${name}' (kh\xF4ng th\u1EC3 ho\xE0n t\xE1c)?`); | |
| if (yes) | |
| await cmdServiceRemove(name, { | |
| yes: true, | |
| source: sel.has("source"), | |
| database: sel.has("database") | |
| }); | |
| }); | |
| } | |
| } | |
| } | |
| async function menuCert() { | |
| while (true) { | |
| printMenu("Qu\u1EA3n l\xFD SSL (Let's Encrypt / certbot)", [ | |
| "Danh s\xE1ch ch\u1EE9ng ch\u1EC9", | |
| "Ph\xE1t h\xE0nh SSL (ch\u1ECDn app)", | |
| "Gia h\u1EA1n m\u1ED9t domain (ch\u1ECDn app)", | |
| "Gia h\u1EA1n T\u1EA4T C\u1EA2", | |
| "Thu h\u1ED3i / g\u1EE1 ch\u1EE9ng ch\u1EC9 (ch\u1ECDn app)" | |
| ]); | |
| const choice = await ask("Ch\u1ECDn: "); | |
| if (choice === "0" || choice === "") return; | |
| if (choice === "1") await guard(() => cmdCertList()); | |
| else if (choice === "2") | |
| await guard(async () => { | |
| const domain2 = await askAppDomain("ph\xE1t h\xE0nh SSL"); | |
| if (!domain2) return; | |
| const saved = getAcmeEmail(); | |
| const email = (await ask(`Email Let's Encrypt${saved ? ` (Enter = ${saved})` : " (Enter = \u0111\u0103ng k\xFD KH\xD4NG email)"}: `)).trim() || saved || ""; | |
| await cmdCertIssue(domain2, { noWww: false, extra: [], email: email || void 0, registerWithoutEmail: !email, redirect: true }); | |
| }); | |
| else if (choice === "3") | |
| await guard(async () => { | |
| const domain2 = await askAppDomain("gia h\u1EA1n"); | |
| if (domain2) cmdCertRenew(domain2, { force: false }); | |
| }); | |
| else if (choice === "4") await guard(() => cmdCertRenew(void 0, { force: false })); | |
| else if (choice === "5") | |
| await guard(async () => { | |
| const domain2 = await askAppDomain("thu h\u1ED3i/g\u1EE1 ch\u1EE9ng ch\u1EC9"); | |
| if (!domain2) return; | |
| const yes = await askYesNo(`Thu h\u1ED3i & xo\xE1 ch\u1EE9ng ch\u1EC9 c\u1EE7a '${domain2}'? Website s\u1EBD m\u1EA5t HTTPS t\u1EDBi khi ph\xE1t h\xE0nh l\u1EA1i.`); | |
| if (yes) await cmdCertRevoke(domain2, { yes: true }); | |
| }); | |
| } | |
| } | |
| async function menuDb() { | |
| while (true) { | |
| printMenu("Qu\u1EA3n l\xFD Database", ["Danh s\xE1ch database", "T\u1EA1o database m\u1EDBi", "Backup m\u1ED9t database"]); | |
| const choice = await ask("Ch\u1ECDn: "); | |
| if (choice === "0" || choice === "") return; | |
| if (choice === "1") await guard(() => cmdDbList()); | |
| else if (choice === "2") await guard(async () => cmdDbCreate(await ask("T\xEAn database: "))); | |
| else if (choice === "3") await guard(async () => cmdDbBackup(await ask("T\xEAn database: "))); | |
| } | |
| } | |
| async function menuBackup() { | |
| while (true) { | |
| printMenu("Sao l\u01B0u (n\xE9n gzip)", [ | |
| "Backup DATABASE ngay (ch\u1ECDn database)", | |
| "Backup m\xE3 ngu\u1ED3n (files) ngay", | |
| "Backup T\u1EA4T C\u1EA2 ngay (database + files)", | |
| "L\xEAn l\u1ECBch t\u1EF1 \u0111\u1ED9ng backup h\xE0ng ng\xE0y", | |
| "G\u1EE1 l\u1ECBch backup t\u1EF1 \u0111\u1ED9ng", | |
| "Danh s\xE1ch c\xE1c b\u1EA3n backup" | |
| ]); | |
| const choice = await ask("Ch\u1ECDn: "); | |
| if (choice === "0" || choice === "") return; | |
| if (choice === "1") | |
| await guard(async () => { | |
| const db2 = await askDatabase("backup"); | |
| if (!db2) return; | |
| const keepDays = await askRetentionDays(); | |
| cmdBackupRun({ target: "db", database: db2 === "__ALL__" ? void 0 : db2, keepDays }); | |
| }); | |
| else if (choice === "2") | |
| await guard(async () => { | |
| const keepDays = await askRetentionDays(); | |
| cmdBackupRun({ target: "files", keepDays }); | |
| }); | |
| else if (choice === "3") | |
| await guard(async () => { | |
| const keepDays = await askRetentionDays(); | |
| cmdBackupRun({ target: "all", keepDays }); | |
| }); | |
| else if (choice === "4") | |
| await guard(async () => { | |
| const time = (await ask("Gi\u1EDD ch\u1EA1y h\xE0ng ng\xE0y (HH:MM, m\u1EB7c \u0111\u1ECBnh 03:00): ")).trim() || "03:00"; | |
| const keepDays = await askRetentionDays(); | |
| cmdBackupSchedule({ time, keepDays, target: "all" }); | |
| }); | |
| else if (choice === "5") await guard(() => cmdBackupUnschedule()); | |
| else if (choice === "6") await guard(() => cmdBackupList()); | |
| } | |
| } | |
| async function menuInfra() { | |
| while (true) { | |
| printMenu("H\u1EA1 t\u1EA7ng (Firewall / fail2ban / Cloudflare / T\u1ED1i \u01B0u)", [ | |
| "\u0110\u1ED3ng b\u1ED9 UFW (SSH + m\u1EDF 80/443 c\xF4ng khai)", | |
| "Tr\u1EA1ng th\xE1i UFW", | |
| "\xC1p c\u1EA5u h\xECnh fail2ban", | |
| "Tr\u1EA1ng th\xE1i fail2ban", | |
| "\u0110\u1ED3ng b\u1ED9 Cloudflare real-IP v\xE0o nginx (ch\u1EA1y ngay)", | |
| "L\xEAn l\u1ECBch t\u1EF1 \u0111\u1ED9ng \u0111\u1ED3ng b\u1ED9 Cloudflare (systemd timer, h\xE0ng ng\xE0y)", | |
| "G\u1EE1 l\u1ECBch t\u1EF1 \u0111\u1ED9ng \u0111\u1ED3ng b\u1ED9 Cloudflare", | |
| "B\u1EA3o v\u1EC7 nginx: ch\u1EB7n truy c\u1EADp IP/Host l\u1EA1 (harden)", | |
| "G\u1EE1 b\u1EA3o v\u1EC7 nginx (unharden)", | |
| "\u0110\u1ED3ng b\u1ED9 c\u1EA5u h\xECnh proxy nginx v\xE0o vhost \u0111\xE3 c\xF3 (b\u1ED9 \u0111\u1EC7m \u2014 s\u1EEDa 502 route s\xE2u)", | |
| "Xem \u0111\u1EC1 xu\u1EA5t t\u1ED1i \u01B0u ph\u1EA7n c\u1EE9ng", | |
| "\xC1p t\u1ED1i \u01B0u ph\u1EA7n c\u1EE9ng (nginx/MariaDB/Redis/sysctl)", | |
| // Thêm vào CUỐI chứ không chèn cạnh các mục nginx ở trên: chèn giữa là | |
| // đánh số lại "Xem/Áp tối ưu phần cứng" — hai mục người dùng đã quen gõ. | |
| "Ch\u1EB7n qu\xE9t l\u1ED7 h\u1ED5ng PHP/WordPress (.php, /wp-admin/ -> 444, log ri\xEAng)", | |
| "G\u1EE1 ch\u1EB7n qu\xE9t l\u1ED7 h\u1ED5ng", | |
| "B\u1ED9 nh\u1EDB: xem tr\u1EA1ng th\xE1i + d\u1EA5u hi\u1EC7u r\xF2 r\u1EC9", | |
| "B\u1ED9 nh\u1EDB: b\u1EADt l\u1EA5y m\u1EABu \u0111\u1ECBnh k\u1EF3 (ph\xE1t hi\u1EC7n r\xF2 r\u1EC9 s\u1EDBm)", | |
| "B\u1ED9 nh\u1EDB: t\u1EAFt l\u1EA5y m\u1EABu \u0111\u1ECBnh k\u1EF3" | |
| ]); | |
| const choice = await ask("Ch\u1ECDn: "); | |
| if (choice === "0" || choice === "") return; | |
| if (choice === "1") await guard(() => cmdFirewallSync({ restrictToCloudflare: false, extraPorts: [], yes: false })); | |
| else if (choice === "2") await guard(() => cmdFirewallStatus()); | |
| else if (choice === "3") await guard(() => cmdFail2banSetup({})); | |
| else if (choice === "4") await guard(() => cmdFail2banStatus()); | |
| else if (choice === "5") await guard(() => cmdCloudflareSync()); | |
| else if (choice === "6") | |
| await guard(async () => { | |
| const time = await ask("Gi\u1EDD ch\u1EA1y h\xE0ng ng\xE0y (HH:MM, m\u1EB7c \u0111\u1ECBnh 01:00): ") || "01:00"; | |
| cmdCloudflareSchedule({ time }); | |
| }); | |
| else if (choice === "7") await guard(() => cmdCloudflareUnschedule()); | |
| else if (choice === "8") await guard(() => cmdNginxHarden()); | |
| else if (choice === "9") await guard(() => cmdNginxUnharden()); | |
| else if (choice === "10") await guard(() => cmdNginxSync()); | |
| else if (choice === "11") await guard(() => cmdTuneShow()); | |
| else if (choice === "12") await guard(() => cmdTuneApply({ yes: false, skipRestart: false, syncUnits: false })); | |
| else if (choice === "13") await guard(() => cmdNginxScanBlock()); | |
| else if (choice === "14") await guard(() => cmdNginxUnscanBlock()); | |
| else if (choice === "15") await guard(() => cmdMemStatus()); | |
| else if (choice === "16") | |
| await guard(async () => { | |
| const raw = await ask("L\u1EA5y m\u1EABu m\u1ED7i bao nhi\xEAu ph\xFAt (m\u1EB7c \u0111\u1ECBnh 15): ") || "15"; | |
| cmdMemWatch({ interval: parseInt(raw, 10) || 15 }); | |
| }); | |
| else if (choice === "17") await guard(() => cmdMemUnwatch()); | |
| } | |
| } | |
| async function menuDoctor() { | |
| while (true) { | |
| printMenu("B\u1EA3o m\u1EADt (doctor)", [ | |
| "Qu\xE9t T\u1EA4T C\u1EA2 (b\u1EA3n v\xE1 h\u1EC7 th\u1ED1ng + dependencies)", | |
| "Ki\u1EC3m tra b\u1EA3n v\xE1 b\u1EA3o m\u1EADt c\u1EE7a h\u1EC7 th\u1ED1ng", | |
| "Qu\xE9t dependencies c\u1EE7a M\u1ECCI app/service", | |
| "Qu\xE9t dependencies c\u1EE7a M\u1ED8T app (ch\u1ECDn)", | |
| "Qu\xE9t dependencies c\u1EE7a M\u1ED8T service (ch\u1ECDn)", | |
| "C\xE0i b\u1EA3n v\xE1 B\u1EA2O M\u1EACT ngay (apt + restart d\u1ECBch v\u1EE5)", | |
| "C\xE0i T\u1EA4T C\u1EA2 b\u1EA3n c\u1EADp nh\u1EADt \u0111ang ch\u1EDD" | |
| ]); | |
| const choice = await ask("Ch\u1ECDn: "); | |
| if (choice === "0" || choice === "") return; | |
| if (choice === "1") | |
| await guard(async () => { | |
| const deep = await askYesNo("Tra th\xEAm tu\u1ED5i b\u1EA3n ph\xE1t h\xE0nh tr\xEAn registry npm (c\u1EA7n m\u1EA1ng, ch\u1EADm h\u01A1n)?", false); | |
| await cmdDoctor({ refresh: true, audit: true, deep }); | |
| }); | |
| else if (choice === "2") await guard(async () => void cmdDoctorSystem({ refresh: true })); | |
| else if (choice === "3") await guard(async () => void await cmdDoctorDeps({ audit: true, deep: false })); | |
| else if (choice === "4") | |
| await guard(async () => { | |
| const domain2 = await askAppDomain("qu\xE9t dependencies"); | |
| if (domain2) await cmdDoctorDeps({ target: domain2, audit: true, deep: false }); | |
| }); | |
| else if (choice === "5") | |
| await guard(async () => { | |
| const name = await askServiceName("qu\xE9t dependencies"); | |
| if (name) await cmdDoctorDeps({ target: name, audit: true, deep: false }); | |
| }); | |
| else if (choice === "6") await guard(() => cmdDoctorUpgrade({ all: false, only: [], yes: false, restart: true })); | |
| else if (choice === "7") await guard(() => cmdDoctorUpgrade({ all: true, only: [], yes: false, restart: true })); | |
| } | |
| } | |
| async function runMenu() { | |
| rl = import_promises.default.createInterface({ input: process.stdin, output: process.stdout }); | |
| try { | |
| while (true) { | |
| printMenu(`napp v${NAPP_VERSION} \u2014 Qu\u1EA3n l\xFD server Node.js`, [ | |
| "Ki\u1EC3m tra m\xF4i tr\u01B0\u1EDDng m\xE1y ch\u1EE7", | |
| "Qu\u1EA3n l\xFD App (web, c\xF3 domain)", | |
| "Qu\u1EA3n l\xFD Background Service (ch\u1EA1y ng\u1EA7m)", | |
| "Qu\u1EA3n l\xFD SSL", | |
| "Qu\u1EA3n l\xFD Database", | |
| "Redis", | |
| "Sao l\u01B0u \u0111\u1ECBnh k\u1EF3", | |
| "H\u1EA1 t\u1EA7ng (Firewall / fail2ban / Cloudflare / T\u1ED1i \u01B0u)", | |
| "B\u1EA3o m\u1EADt: b\u1EA3n v\xE1 h\u1EC7 th\u1ED1ng & r\u1EE7i ro dependencies (doctor)", | |
| "C\u1EADp nh\u1EADt napp" | |
| ]); | |
| const choice = await ask("Ch\u1ECDn: "); | |
| if (choice === "0" || choice === "" || choice.toLowerCase() === "q") break; | |
| if (choice === "1") await guard(() => cmdCheck({ fix: false, yes: false })); | |
| else if (choice === "2") await menuApp(); | |
| else if (choice === "3") await menuService(); | |
| else if (choice === "4") await menuCert(); | |
| else if (choice === "5") await menuDb(); | |
| else if (choice === "6") await guard(() => cmdRedisAllocations()); | |
| else if (choice === "7") await menuBackup(); | |
| else if (choice === "8") await menuInfra(); | |
| else if (choice === "9") await menuDoctor(); | |
| else if (choice === "10") await guard(() => cmdUpdate()); | |
| } | |
| } finally { | |
| rl.close(); | |
| } | |
| } | |
| // src/index.ts | |
| var program2 = new Command(); | |
| program2.name("napp").description("napp \u2014 qu\u1EA3n l\xFD server l\u01B0u tr\u1EEF nhi\u1EC1u \u1EE9ng d\u1EE5ng Node.js/Bun (domain, SSL, systemd, MariaDB, Redis, nginx, fail2ban, UFW, Cloudflare, backup, t\u1ED1i \u01B0u ph\u1EA7n c\u1EE9ng, OTA update)").version(NAPP_VERSION, "-V, --version").option("--dry-run", "ch\u1EC9 in ra c\xE1c b\u01B0\u1EDBc s\u1EBD th\u1EF1c hi\u1EC7n, kh\xF4ng thay \u0111\u1ED5i g\xEC th\u1EADt").option("--verbose", "in chi ti\u1EBFt c\xE1c l\u1EC7nh h\u1EC7 th\u1ED1ng \u0111\u01B0\u1EE3c th\u1EF1c thi").hook("preAction", (thisCmd) => { | |
| const opts = thisCmd.opts(); | |
| setDryRun(Boolean(opts.dryRun)); | |
| state.verbose = Boolean(opts.verbose); | |
| }); | |
| program2.addHelpText( | |
| "after", | |
| ` | |
| B\u1EAFt \u0111\u1EA7u nhanh: | |
| sudo napp m\u1EDF menu t\u01B0\u01A1ng t\xE1c (g\xF5 s\u1ED1, 0 \u0111\u1EC3 quay l\u1EA1i) | |
| sudo napp check --fix ki\u1EC3m tra + t\u1EF1 c\xE0i th\xE0nh ph\u1EA7n c\xF2n thi\u1EBFu | |
| sudo napp app create <domain> --repo <url> --db --redis | |
| sudo napp cert issue <domain> --email <email> | |
| sudo napp tune apply t\u1ED1i \u01B0u theo ph\u1EA7n c\u1EE9ng (ch\u1EA1y l\u1EA1i khi n\xE2ng c\u1EA5p server) | |
| Sau khi c\u1EADp nh\u1EADt napp (b\u1EA3n c\u0169 \u0111\u1EC3 l\u1EA1i c\u1EA5u h\xECnh \u0111\xE3 h\u1ECFng, kh\xF4ng t\u1EF1 s\u1EEDa): | |
| sudo napp nginx sync g\u1EE1 b\u1ED9 \u0111\u1EC7m proxy 16k n\u1ED9i tuy\u1EBFn kh\u1ECFi vhost c\u0169 | |
| -> h\u1EBFt 502 'upstream sent too big header' \u1EDF | |
| route SvelteKit l\u1ED3ng s\xE2u. | |
| \u0110\u1ED3ng th\u1EDDi ch\xE8n d\xF2ng 'include' file location v\xE0o | |
| vhost t\u1EA1o b\u1EB1ng b\u1EA3n napp c\u0169, v\xE0 B\u1EACT ch\u1EB7n qu\xE9t l\u1ED7 | |
| h\u1ED5ng (.php/wp-admin/phpmyadmin -> 444, log ri\xEAng) | |
| sudo napp fail2ban setup b\u1EADt jail 'napp-scanner' \u2014 ban IP qu\xE9t ngay \u1EDF | |
| t\u01B0\u1EDDng l\u1EEDa. \u0110\xE2y m\u1EDBi l\xE0 ch\u1ED7 ti\u1EBFt ki\u1EC7m t\xE0i nguy\xEAn | |
| th\u1EADt: 444 v\u1EABn ph\u1EA3i tr\u1EA3 ti\u1EC1n b\u1EAFt tay TLS. | |
| C\u0169ng s\u1EEDa 'backend' c\u1EE7a c\xE1c jail nginx: b\u1EA3n c\u0169 | |
| \u0111\u1EC3 backend=systemd n\xEAn ch\xFAng KH\xD4NG \u0111\u1ECDc \u0111\u01B0\u1EE3c | |
| access log (file), t\u1EE9c ch\u01B0a t\u1EEBng ban \u0111\u01B0\u1EE3c ai | |
| sudo napp tune apply c\xE2n \u0111\u1ED1i l\u1EA1i heap V8 THEO TR\u1ECCNG S\u1ED0 (web app g\u1EA5p | |
| \u0111\xF4i background service) v\xE0 v\xE1 CPUWeight/ | |
| IOWeight/MemoryHigh v\xE0o unit t\u1EA1o t\u1EEB b\u1EA3n c\u0169 \u2014 | |
| unit c\u0169 kh\xF4ng c\xF3 d\xF2ng n\xE0o trong s\u1ED1 \u0111\xF3, n\xEAn | |
| worker n\xE9n \u1EA3nh v\u1EABn tranh CPU NGANG C\u01A0 v\u1EDBi web | |
| app. L\u01AFU \xDD: heap \u0111\u1ED5i th\xEC app ph\u1EA3i restart. | |
| Th\xEAm --skip-restart \u0111\u1EC3 \xE1p ngay ph\u1EA7n \u01B0u ti\xEAn | |
| CPU (daemon-reload l\xE0 \u0111\u1EE7) v\xE0 ho\xE3n ph\u1EA7n heap | |
| sudo napp check b\xE1o Redis c\xF2n maxmemory-policy kh\xE1c noeviction | |
| (BullMQ m\u1EA5t job), vhost n\xE0o c\xF2n b\u1ED9 \u0111\u1EC7m c\u0169, V\xC0 | |
| app n\xE0o c\xF2n \u0111\u1EA9y to\xE0n b\u1ED9 asset t\u0129nh qua Node | |
| (ch\u1EADm m\xE0 kh\xF4ng c\xF3 l\u1ED7i n\xE0o \u0111\u1EC3 l\u1EA7n ra) | |
| sudo napp app set <domain> --auto-static | |
| nh\u1EADn di\u1EC7n framework t\u1EEB th\u01B0 m\u1EE5c build r\u1ED3i cho | |
| nginx tr\u1EA3 th\u1EB3ng asset. T\u1EF1 c\u1EA5p lu\xF4n quy\u1EC1n \u0111\u1ECDc | |
| cho nginx \u2014 thi\u1EBFu b\u01B0\u1EDBc \u0111\xF3 th\xEC asset tr\u1EA3 403 | |
| sudo napp tune apply --sync-units | |
| CH\u1EC8 khi c\u1EA7n \u0111\u1EA9y hardening/template m\u1EDBi xu\u1ED1ng | |
| unit t\u1EA1o t\u1EEB b\u1EA3n napp c\u0169. Kh\xF4ng c\xF3 c\u1EDD n\xE0y, | |
| tune apply ch\u1EC9 s\u1EEDa \u0111\xFAng c\xE1c d\xF2ng c\u1EA7n s\u1EEDa | |
| (--max-old-space-size, CPUWeight, IOWeight, | |
| MemoryHigh) v\xE0 kh\xF4ng \u0111\u1EE5ng ExecStart/Standard*/ | |
| User/Group b\u1EA1n s\u1EEDa tay | |
| Nghi ng\u1EDD r\xF2 r\u1EC9 b\u1ED9 nh\u1EDB (app t\u1EF1 ch\u1EBFt r\u1ED3i t\u1EF1 s\u1ED1ng l\u1EA1i m\xE0 kh\xF4ng ai hay): | |
| sudo napp mem status b\u1ED9 nh\u1EDB hi\u1EC7n t\u1EA1i + S\u1ED0 L\u1EA6N systemd \u0111\xE3 \xE2m th\u1EA7m | |
| kh\u1EDFi \u0111\u1ED9ng l\u1EA1i. Unit napp \u0111\u1EC1u 'Restart=always' | |
| n\xEAn app r\xF2 r\u1EC9 ch\u1EBFt r\u1ED3i t\u1EF1 d\u1EADy, l\u1EB7p nhi\u1EC1u ng\xE0y | |
| sudo napp mem watch l\u1EA5y m\u1EABu \u0111\u1ECBnh k\u1EF3 -> 'napp mem trend' k\u1EBFt lu\u1EADn | |
| \u0111\u01B0\u1EE3c xu h\u01B0\u1EDBng (c\u1EA7n \xEDt nh\u1EA5t 6 gi\u1EDD d\u1EEF li\u1EC7u) | |
| sudo napp mem guard <app> b\u1EADt c\u1EDD Node t\u1EF1 ch\u1EE5p heap TR\u01AF\u1EDAC khi ch\u1EBFt v\xEC OOM | |
| sudo napp mem snapshot <app> ch\u1EE5p heap ngay, app v\u1EABn ch\u1EA1y -> m\u1EDF b\u1EB1ng | |
| Chrome DevTools > Memory \u0111\u1EC3 t\xECm th\u1EE7 ph\u1EA1m | |
| Worker c\u1EE7a m\u1ED9t app web (hai n\u1EEDa c\u1EE7a c\xF9ng m\u1ED9t s\u1EA3n ph\u1EA9m): | |
| sudo napp service create <name> --run-as <domain> --share-redis-with <domain> | |
| --run-as ch\u1EA1y b\u1EB1ng user c\u1EE7a app -> \u0111\u1ECDc/ghi \u0111\u01B0\u1EE3c file c\u1EE7a app | |
| --share-redis-with chung keyspace -> h\xE0ng \u0111\u1EE3i m\u1EDBi ch\u1EA1y | |
| sudo napp service set <name> --run-as <domain> (\u0111\u1ED5i cho service \u0110\xC3 T\u1EA0O) | |
| Chi ti\u1EBFt t\u1EEBng l\u1EC7nh: napp <l\u1EC7nh> --help \xB7 l\u1ECBch s\u1EED thay \u0111\u1ED5i: napp changelog | |
| ` | |
| ); | |
| program2.command("check").description("ki\u1EC3m tra m\xF4i tr\u01B0\u1EDDng m\xE1y ch\u1EE7 (Node.js, nginx, certbot, MariaDB, Redis, fail2ban, UFW) + ph\xE1t hi\u1EC7n c\u1EA5u h\xECnh Redis/nginx \u0111\xE3 l\u1ED7i th\u1EDDi").option("--fix", "t\u1EF1 c\xE0i \u0111\u1EB7t/kh\u1EDFi \u0111\u1ED9ng c\xE1c th\xE0nh ph\u1EA7n c\xF2n thi\u1EBFu (c\u1EA7n sudo)").option("-y, --yes", "kh\xF4ng h\u1ECFi x\xE1c nh\u1EADn khi d\xF9ng --fix").action(async (opts) => cmdCheck({ fix: Boolean(opts.fix), yes: Boolean(opts.yes) })); | |
| var doctor = program2.command("doctor").description("soi r\u1EE7i ro b\u1EA3o m\u1EADt: b\u1EA3n v\xE1 h\u1EC7 th\u1ED1ng \u0111ang ch\u1EDD + r\u1EE7i ro chu\u1ED7i cung \u1EE9ng c\u1EE7a dependencies").option("--no-refresh", "kh\xF4ng ch\u1EA1y 'apt-get update' tr\u01B0\u1EDBc khi ki\u1EC3m tra").option("--no-audit", "b\u1ECF qua audit l\u1ED7 h\u1ED5ng c\u1EE7a package manager (kh\xF4ng c\u1EA7n m\u1EA1ng)").option("--deep", "tra th\xEAm tu\u1ED5i b\u1EA3n ph\xE1t h\xE0nh c\u1EE7a dependency tr\u1EF1c ti\u1EBFp tr\xEAn registry npm (c\u1EA7n m\u1EA1ng)").action(async (opts) => cmdDoctor({ refresh: opts.refresh !== false, audit: opts.audit !== false, deep: Boolean(opts.deep) })); | |
| doctor.command("system").description("ki\u1EC3m tra b\u1EA3n v\xE1 b\u1EA3o m\u1EADt \u0111ang ch\u1EDD, d\u1ECBch v\u1EE5 c\xF2n n\u1EA1p th\u01B0 vi\u1EC7n c\u0169, CVE nginx, v\xF2ng \u0111\u1EDDi Node.js").option("--no-refresh", "kh\xF4ng ch\u1EA1y 'apt-get update' tr\u01B0\u1EDBc khi ki\u1EC3m tra").action(async (opts) => { | |
| cmdDoctorSystem({ refresh: opts.refresh !== false }); | |
| }); | |
| doctor.command("deps [target]").description("qu\xE9t r\u1EE7i ro chu\u1ED7i cung \u1EE9ng trong dependencies (b\u1ECF tr\u1ED1ng target = qu\xE9t m\u1ECDi app + service)").option("--no-audit", "b\u1ECF qua audit l\u1ED7 h\u1ED5ng c\u1EE7a package manager (kh\xF4ng c\u1EA7n m\u1EA1ng)").option("--deep", "tra th\xEAm tu\u1ED5i b\u1EA3n ph\xE1t h\xE0nh c\u1EE7a dependency tr\u1EF1c ti\u1EBFp tr\xEAn registry npm (c\u1EA7n m\u1EA1ng)").action(async (target, opts) => { | |
| await cmdDoctorDeps({ target, audit: opts.audit !== false, deep: Boolean(opts.deep) }); | |
| }); | |
| doctor.command("upgrade").description("c\xE0i b\u1EA3n v\xE1 (m\u1EB7c \u0111\u1ECBnh CH\u1EC8 b\u1EA3n v\xE1 b\u1EA3o m\u1EADt) r\u1ED3i kh\u1EDFi \u0111\u1ED9ng l\u1EA1i d\u1ECBch v\u1EE5 \u0111\u1EC3 b\u1EA3n v\xE1 c\xF3 hi\u1EC7u l\u1EF1c").option("--all", "c\xE0i m\u1ECDi b\u1EA3n c\u1EADp nh\u1EADt \u0111ang ch\u1EDD, kh\xF4ng ch\u1EC9 b\u1EA3n v\xE1 b\u1EA3o m\u1EADt").option("--only <pkg...>", "ch\u1EC9 n\xE2ng c\u1EA5p c\xE1c g\xF3i n\xE0y (vd: --only nginx)").option("--no-restart", "kh\xF4ng t\u1EF1 kh\u1EDFi \u0111\u1ED9ng l\u1EA1i d\u1ECBch v\u1EE5 sau khi c\xE0i (ch\u1EC9 in h\u01B0\u1EDBng d\u1EABn)").option("-y, --yes", "kh\xF4ng h\u1ECFi x\xE1c nh\u1EADn").action( | |
| async (opts) => cmdDoctorUpgrade({ | |
| all: Boolean(opts.all), | |
| only: opts.only ?? [], | |
| yes: Boolean(opts.yes), | |
| restart: opts.restart !== false | |
| }) | |
| ); | |
| var app = program2.command("app").description("qu\u1EA3n l\xFD c\xE1c \u1EE9ng d\u1EE5ng Node.js/Bun"); | |
| app.command("create <domain>").description("t\u1EA1o app m\u1EDBi: user h\u1EC7 th\u1ED1ng ri\xEAng, clone repo, systemd service, nginx vhost").option("--port <port>", "c\u1ED5ng n\u1ED9i b\u1ED9 (m\u1EB7c \u0111\u1ECBnh: t\u1EF1 c\u1EA5p ph\xE1t 3000-3999)", (v) => parseInt(v, 10)).option("--repo <url>", "git repo \u0111\u1EC3 clone (b\u1ECF tr\u1ED1ng \u0111\u1EC3 t\u1EA1o app m\u1EABu r\u1ED7ng)").option("--branch <branch>", "branch git", "main").option("--token <token>", "Personal Access Token \u0111\u1EC3 clone repo PRIVATE qua HTTPS (kh\xF4ng h\u1ECFi m\u1EADt kh\u1EA9u)").option("--ssh-key <path>", "deploy key \u0111\u1EC3 clone repo PRIVATE qua SSH \u2014 \u0111\u01B0\u1EDDng d\u1EABn file HO\u1EB6C n\u1ED9i dung key").addOption(new Option("--runtime <runtime>", "runtime ch\u1EA1y app").choices(["node", "bun"]).default("node")).addOption(new Option("--package-manager <pm>", "tr\xECnh qu\u1EA3n l\xFD g\xF3i ph\u1EE5 thu\u1ED9c (m\u1EB7c \u0111\u1ECBnh: bun n\u1EBFu runtime bun, c\xF2n l\u1EA1i npm)").choices(["npm", "pnpm", "yarn", "bun"])).option("--install-cmd <cmd>", "l\u1EC7nh c\xE0i dependencies (m\u1EB7c \u0111\u1ECBnh theo package manager)").option("--build-cmd <cmd>", "l\u1EC7nh build (vd: 'npm run build')").option("--start-cmd <cmd>", "l\u1EC7nh kh\u1EDFi \u0111\u1ED9ng (m\u1EB7c \u0111\u1ECBnh theo runtime, vd: 'npm start')").option( | |
| "--address-header", | |
| "\u0111\u1EB7t ADDRESS_HEADER/XFF_DEPTH cho SvelteKit adapter-node \u2014 CH\u1EC8 d\xF9ng khi app KH\xD4NG t\u1EF1 ph\xE2n gi\u1EA3i IP kh\xE1ch (xem README)" | |
| ).option("--db", "t\u1EA1o k\xE8m database MariaDB ri\xEAng cho app").option("--redis", "c\u1EA5p Redis DB ri\xEAng cho app (0-15)").option("--redis-db <n>", "d\xF9ng Redis DB CH\u1EC8 \u0110\u1ECANH (cho ph\xE9p d\xF9ng CHUNG v\u1EDBi \u0111\u01A1n v\u1ECB kh\xE1c)", (v) => parseInt(v, 10)).option("--share-redis-with <domain|name>", "d\xF9ng CHUNG Redis DB v\u1EDBi app/service \u0111\xE3 c\xF3 (b\u1EAFt bu\u1ED9c cho c\u1EB7p web + worker)").option("--app-dir <path>", "monorepo: th\u01B0 m\u1EE5c con ch\u1EE9a app, t\u01B0\u01A1ng \u0111\u1ED1i so v\u1EDBi m\xE3 ngu\u1ED3n (vd 'apps/backend')").option("--max-body <size>", "client_max_body_size c\u1EE7a nginx (m\u1EB7c \u0111\u1ECBnh 20M; t\u0103ng n\u1EBFu app cho upload file l\u1EDBn)").option("--static-root <dir>", "th\u01B0 m\u1EE5c asset build \u0111\u1EC3 NGINX tr\u1EA3 th\u1EB3ng thay v\xEC qua Node (vd '<webRoot>/build/client')").option("--upload-dir <dir>", "th\u01B0 m\u1EE5c file NG\u01AF\u1EDCI D\xD9NG T\u1EA2I L\xCAN l\xFAc ch\u1EA1y \u2014 KH\xC1C --static-root, xem README (vd '<webRoot>/static/uploads')").option("--upload-prefix <path>", "ti\u1EC1n t\u1ED1 URL c\u1EE7a --upload-dir (m\u1EB7c \u0111\u1ECBnh '/uploads/')").option("--hotlink-protect", "ch\u1EC9 cho nh\xFAng asset/\u1EA3nh t\u1EEB domain c\u1EE7a site (CORP do tr\xECnh duy\u1EC7t th\u1EF1c thi + ki\u1EC3m tra Referer)").option("--hotlink-strict", "ch\u1EB7t h\u01A1n: B\u1ECE 'none'/'blocked' kh\u1ECFi valid_referers \u2014 \u0111\u1ED5i l\u1EA1i M\u1EA4T \u1EA3nh preview khi chia s\u1EBB link").option( | |
| "--hotlink-allow <domain...>", | |
| "domain NGO\xC0I c\u0169ng \u0111\u01B0\u1EE3c ph\xE9p nh\xFAng, l\u1EB7p l\u1EA1i \u0111\u01B0\u1EE3c (vd 'partner.com' ho\u1EB7c '*.cdn.net')", | |
| (v, prev) => [...prev, v], | |
| [] | |
| ).option( | |
| "--static-prefix <path...>", | |
| "ti\u1EC1n t\u1ED1 URL ph\u1EE5c v\u1EE5 t\u1EEB --static-root, l\u1EB7p l\u1EA1i \u0111\u01B0\u1EE3c (SvelteKit: /_app/ \xB7 Nuxt: /_nuxt/ \xB7 Astro: /_astro/). Next.js d\xF9ng --static-alias", | |
| (v, prev) => [...prev, v], | |
| [] | |
| ).option( | |
| "--static-alias <prefix=dir...>", | |
| "ti\u1EC1n t\u1ED1 URL ph\u1EE5c v\u1EE5 b\u1EB1ng 'alias', l\u1EB7p l\u1EA1i \u0111\u01B0\u1EE3c \u2014 d\xF9ng khi URL kh\xE1c t\xEAn th\u01B0 m\u1EE5c (Next.js: '/_next/static/=<webRoot>/.next/static')", | |
| (v, prev) => [...prev, v], | |
| [] | |
| ).option("--auto-static", "t\u1EF1 nh\u1EADn di\u1EC7n framework t\u1EEB th\u01B0 m\u1EE5c build v\xE0 cho nginx tr\u1EA3 th\u1EB3ng asset (SvelteKit, Next.js, Nuxt, SolidStart, Astro)").option("--env <KEY=VALUE...>", "bi\u1EBFn m\xF4i tr\u01B0\u1EDDng b\u1ED5 sung, c\xF3 th\u1EC3 l\u1EB7p l\u1EA1i nhi\u1EC1u l\u1EA7n", (v, prev) => [...prev, v], []).action(async (domain2, opts) => { | |
| await cmdAppCreate(domain2, { | |
| port: opts.port, | |
| repo: opts.repo, | |
| branch: opts.branch, | |
| token: opts.token, | |
| sshKey: opts.sshKey, | |
| runtime: opts.runtime, | |
| packageManager: opts.packageManager, | |
| installCmd: opts.installCmd, | |
| buildCmd: opts.buildCmd, | |
| startCmd: opts.startCmd, | |
| db: Boolean(opts.db), | |
| redis: Boolean(opts.redis), | |
| redisDb: opts.redisDb, | |
| shareRedisWith: opts.shareRedisWith, | |
| appDir: opts.appDir, | |
| addressHeader: Boolean(opts.addressHeader), | |
| maxBody: opts.maxBody, | |
| staticRoot: opts.staticRoot, | |
| staticPrefix: (opts.staticPrefix ?? []).length > 0 ? opts.staticPrefix : void 0, | |
| staticAlias: (opts.staticAlias ?? []).length > 0 ? opts.staticAlias : void 0, | |
| autoStatic: Boolean(opts.autoStatic), | |
| uploadDir: opts.uploadDir, | |
| uploadPrefix: opts.uploadPrefix, | |
| hotlinkProtect: Boolean(opts.hotlinkProtect), | |
| hotlinkStrict: Boolean(opts.hotlinkStrict), | |
| hotlinkAllow: (opts.hotlinkAllow ?? []).length > 0 ? opts.hotlinkAllow : void 0, | |
| env: opts.env ?? [] | |
| }); | |
| }); | |
| app.command("deploy <domain>").description("git pull + c\xE0i dependencies + build + restart service").action(async (domain2) => cmdAppDeploy(domain2)); | |
| app.command("remove <domain>").description("g\u1EE1 app kh\u1ECFi napp \u2014 ch\u1ECDn xo\xE1 nginx / ssl / m\xE3 ngu\u1ED3n / database (service systemd lu\xF4n b\u1ECB g\u1EE1)").option("-y, --yes", "kh\xF4ng h\u1ECFi x\xE1c nh\u1EADn (m\u1EB7c \u0111\u1ECBnh: xo\xE1 nginx + ssl, GI\u1EEE m\xE3 ngu\u1ED3n + database)").option("--all", "xo\xE1 t\u1EA5t c\u1EA3: nginx, ssl, m\xE3 ngu\u1ED3n (+ user), database").option("--source", "xo\xE1 lu\xF4n m\xE3 ngu\u1ED3n v\xE0 user h\u1EC7 th\u1ED1ng c\u1EE7a app").option("--db", "xo\xE1 lu\xF4n database").option("--keep-nginx", "gi\u1EEF l\u1EA1i c\u1EA5u h\xECnh nginx").option("--keep-ssl", "gi\u1EEF l\u1EA1i ch\u1EE9ng ch\u1EC9 SSL").option("--keep-db", "gi\u1EEF l\u1EA1i database (m\u1EB7c \u0111\u1ECBnh \u0111\xE3 gi\u1EEF; c\u1EDD n\xE0y \u0111\u1EC3 t\u01B0\u01A1ng th\xEDch script c\u0169)").action( | |
| async (domain2, opts) => cmdAppRemove(domain2, { | |
| yes: Boolean(opts.yes), | |
| nginx: opts.all ? true : !opts.keepNginx, | |
| ssl: opts.all ? true : !opts.keepSsl, | |
| source: Boolean(opts.all || opts.source), | |
| database: opts.all ? true : Boolean(opts.db) && !opts.keepDb | |
| }) | |
| ); | |
| app.command("list").description("li\u1EC7t k\xEA c\xE1c app \u0111ang qu\u1EA3n l\xFD").action(() => cmdAppList()); | |
| app.command("restart <domain>").description("kh\u1EDFi \u0111\u1ED9ng l\u1EA1i app").action((domain2) => cmdAppRestart(domain2)); | |
| app.command("stop <domain>").description("d\u1EEBng app").action((domain2) => cmdAppStop(domain2)); | |
| app.command("start <domain>").description("kh\u1EDFi \u0111\u1ED9ng app").action((domain2) => cmdAppStart(domain2)); | |
| app.command("logs <domain>").description("xem log c\u1EE7a app (qua journalctl)").option("-f, --follow", "theo d\xF5i log li\xEAn t\u1EE5c").option("-n, --lines <n>", "s\u1ED1 d\xF2ng log", (v) => parseInt(v, 10), 100).action((domain2, opts) => cmdAppLogs(domain2, { follow: Boolean(opts.follow), lines: opts.lines })); | |
| app.command("set <domain>").description("\u0111\u1ED5i c\u1EA5u h\xECnh NGINX c\u1EE7a app \u0110\xC3 T\u1EA0O (asset t\u0129nh, file t\u1EA3i l\xEAn, ch\u1EB7n hotlink, gi\u1EDBi h\u1EA1n upload) \u2014 xem --auto-static").option("--static-root <dir>", "th\u01B0 m\u1EE5c asset build \u0111\u1EC3 NGINX tr\u1EA3 th\u1EB3ng thay v\xEC qua Node").option( | |
| "--static-prefix <path...>", | |
| "ti\u1EC1n t\u1ED1 URL ph\u1EE5c v\u1EE5 t\u1EEB --static-root, l\u1EB7p l\u1EA1i \u0111\u01B0\u1EE3c (SvelteKit: /_app/ \xB7 Nuxt: /_nuxt/ \xB7 Astro: /_astro/). Next.js d\xF9ng --static-alias", | |
| (v, prev) => [...prev, v], | |
| [] | |
| ).option( | |
| "--static-alias <prefix=dir...>", | |
| "ti\u1EC1n t\u1ED1 URL ph\u1EE5c v\u1EE5 b\u1EB1ng 'alias', l\u1EB7p l\u1EA1i \u0111\u01B0\u1EE3c \u2014 d\xF9ng khi URL kh\xE1c t\xEAn th\u01B0 m\u1EE5c (Next.js: '/_next/static/=<webRoot>/.next/static')", | |
| (v, prev) => [...prev, v], | |
| [] | |
| ).option("--auto-static", "nh\u1EADn di\u1EC7n framework t\u1EEB th\u01B0 m\u1EE5c build r\u1ED3i \xE1p c\u1EA5u h\xECnh t\u0129nh ph\xF9 h\u1EE3p (SvelteKit, Next.js, Nuxt, SolidStart, Astro)").option("--upload-dir <dir>", "th\u01B0 m\u1EE5c file NG\u01AF\u1EDCI D\xD9NG T\u1EA2I L\xCAN l\xFAc ch\u1EA1y (kh\xE1c --static-root)").option("--upload-prefix <path>", "ti\u1EC1n t\u1ED1 URL c\u1EE7a --upload-dir (m\u1EB7c \u0111\u1ECBnh '/uploads/')").option("--hotlink-protect", "ch\u1EC9 cho nh\xFAng asset/\u1EA3nh t\u1EEB domain c\u1EE7a site (CORP + ki\u1EC3m tra Referer)").option("--no-hotlink-protect", "t\u1EAFt ch\u1EB7n hotlink").option("--hotlink-strict", "ch\u1EB7t h\u01A1n: B\u1ECE 'none'/'blocked' kh\u1ECFi valid_referers \u2014 \u0111\u1ED5i l\u1EA1i M\u1EA4T \u1EA3nh preview khi chia s\u1EBB link").option("--no-hotlink-strict", "quay l\u1EA1i m\u1EE9c m\u1EB7c \u0111\u1ECBnh (cho ph\xE9p 'none'/'blocked')").option( | |
| "--hotlink-allow <domain...>", | |
| "domain NGO\xC0I c\u0169ng \u0111\u01B0\u1EE3c ph\xE9p nh\xFAng, l\u1EB7p l\u1EA1i \u0111\u01B0\u1EE3c", | |
| (v, prev) => [...prev, v], | |
| [] | |
| ).option("--max-body <size>", "client_max_body_size c\u1EE7a nginx (vd '100M')").option("--scan-block", "b\u1EADt l\u1EA1i ch\u1EB7n qu\xE9t l\u1ED7 h\u1ED5ng cho site n\xE0y (m\u1EB7c \u0111\u1ECBnh \u0111\xE3 b\u1EADt)").option("--no-scan-block", "T\u1EAET ch\u1EB7n qu\xE9t l\u1ED7 h\u1ED5ng cho RI\xCANG site n\xE0y \u2014 ch\u1EC9 c\u1EA7n khi site th\u1EADt s\u1EF1 ph\u1EE5c v\u1EE5 .php qua upstream kh\xE1c").action( | |
| (domain2, opts) => cmdAppSet(domain2, { | |
| staticRoot: opts.staticRoot, | |
| staticPrefix: (opts.staticPrefix ?? []).length > 0 ? opts.staticPrefix : void 0, | |
| staticAlias: (opts.staticAlias ?? []).length > 0 ? opts.staticAlias : void 0, | |
| autoStatic: Boolean(opts.autoStatic), | |
| uploadDir: opts.uploadDir, | |
| uploadPrefix: opts.uploadPrefix, | |
| // commander đặt hotlinkProtect=true khi có --hotlink-protect và false khi | |
| // có --no-hotlink-protect; KHÔNG truyền cờ nào thì nó là undefined nhờ | |
| // không khai báo default -> cmdAppSet bỏ qua, không ghi đè giá trị cũ. | |
| hotlinkProtect: opts.hotlinkProtect, | |
| hotlinkStrict: opts.hotlinkStrict, | |
| hotlinkAllow: (opts.hotlinkAllow ?? []).length > 0 ? opts.hotlinkAllow : void 0, | |
| maxBody: opts.maxBody, | |
| scanBlock: opts.scanBlock | |
| }) | |
| ); | |
| app.command("env-set <domain> <pairs...>").description("c\u1EADp nh\u1EADt bi\u1EBFn m\xF4i tr\u01B0\u1EDDng trong .env (d\u1EA1ng KEY=VALUE, c\xF3 th\u1EC3 truy\u1EC1n nhi\u1EC1u)").action((domain2, pairs) => cmdAppEnvSet(domain2, pairs)); | |
| var service = program2.command("service").description("qu\u1EA3n l\xFD \u1EE9ng d\u1EE5ng ch\u1EA1y ng\u1EA7m (background service, kh\xF4ng domain/nginx)"); | |
| service.command("create <name>").description("t\u1EA1o background service: user h\u1EC7 th\u1ED1ng ri\xEAng (ho\u1EB7c m\u01B0\u1EE3n user app web b\u1EB1ng --run-as), clone repo, systemd service (kh\xF4ng nginx/domain)").option("--port <port>", "c\u1ED5ng n\u1ED9i b\u1ED9 (m\u1EB7c \u0111\u1ECBnh: KH\xD4NG c\u1EA5p; ch\u1EC9 \u0111\u1EB7t khi service t\u1EF1 bind, vd health-check)", (v) => parseInt(v, 10)).option("--repo <url>", "git repo \u0111\u1EC3 clone (b\u1ECF tr\u1ED1ng \u0111\u1EC3 t\u1EA1o worker m\u1EABu r\u1ED7ng)").option("--branch <branch>", "branch git", "main").option("--token <token>", "Personal Access Token \u0111\u1EC3 clone repo PRIVATE qua HTTPS (kh\xF4ng h\u1ECFi m\u1EADt kh\u1EA9u)").option("--ssh-key <path>", "deploy key \u0111\u1EC3 clone repo PRIVATE qua SSH \u2014 \u0111\u01B0\u1EDDng d\u1EABn file HO\u1EB6C n\u1ED9i dung key").addOption(new Option("--runtime <runtime>", "runtime ch\u1EA1y service").choices(["node", "bun"]).default("node")).addOption(new Option("--package-manager <pm>", "tr\xECnh qu\u1EA3n l\xFD g\xF3i ph\u1EE5 thu\u1ED9c (m\u1EB7c \u0111\u1ECBnh: bun n\u1EBFu runtime bun, c\xF2n l\u1EA1i npm)").choices(["npm", "pnpm", "yarn", "bun"])).option("--install-cmd <cmd>", "l\u1EC7nh c\xE0i dependencies (m\u1EB7c \u0111\u1ECBnh theo package manager)").option("--build-cmd <cmd>", "l\u1EC7nh build (vd: 'npm run build')").option("--start-cmd <cmd>", "l\u1EC7nh kh\u1EDFi \u0111\u1ED9ng (m\u1EB7c \u0111\u1ECBnh 'npm start' theo package.json; vd: 'node worker.js')").option("--db", "t\u1EA1o k\xE8m database MariaDB ri\xEAng cho service").option("--redis", "c\u1EA5p Redis DB ri\xEAng cho service (0-15)").option("--redis-db <n>", "d\xF9ng Redis DB CH\u1EC8 \u0110\u1ECANH (cho ph\xE9p d\xF9ng CHUNG v\u1EDBi \u0111\u01A1n v\u1ECB kh\xE1c)", (v) => parseInt(v, 10)).option("--share-redis-with <domain|name>", "d\xF9ng CHUNG Redis DB v\u1EDBi app/service \u0111\xE3 c\xF3 \u2014 B\u1EAET BU\u1ED8C n\u1EBFu service n\xE0y ti\xEAu th\u1EE5 h\xE0ng \u0111\u1EE3i c\u1EE7a m\u1ED9t web app").option("--app-dir <path>", "monorepo: th\u01B0 m\u1EE5c con ch\u1EE9a worker, t\u01B0\u01A1ng \u0111\u1ED1i so v\u1EDBi m\xE3 ngu\u1ED3n (vd 'apps/worker')").option( | |
| "--run-as <domain|name>", | |
| "ch\u1EA1y worker b\u1EB1ng user h\u1EC7 th\u1ED1ng c\u1EE7a app/service \u0110\xC3 C\xD3 (thay v\xEC user ri\xEAng) \u2014 c\u1EA7n khi worker \u0111\u1ECDc/ghi FILE c\u1EE7a app \u0111\xF3, vd n\xE9n \u1EA3nh trong th\u01B0 m\u1EE5c upload" | |
| ).option( | |
| "--write-dir <path>", | |
| "c\u1EA5p th\xEAm quy\u1EC1n GHI v\xE0o \u0111\u01B0\u1EDDng d\u1EABn tuy\u1EC7t \u0111\u1ED1i ngo\xE0i m\xE3 ngu\u1ED3n service (ReadWritePaths), l\u1EB7p l\u1EA1i \u0111\u01B0\u1EE3c", | |
| (v, prev) => [...prev, v], | |
| [] | |
| ).option("--env <KEY=VALUE...>", "bi\u1EBFn m\xF4i tr\u01B0\u1EDDng b\u1ED5 sung, c\xF3 th\u1EC3 l\u1EB7p l\u1EA1i nhi\u1EC1u l\u1EA7n", (v, prev) => [...prev, v], []).action(async (name, opts) => { | |
| await cmdServiceCreate(name, { | |
| port: opts.port, | |
| repo: opts.repo, | |
| branch: opts.branch, | |
| token: opts.token, | |
| sshKey: opts.sshKey, | |
| runtime: opts.runtime, | |
| packageManager: opts.packageManager, | |
| installCmd: opts.installCmd, | |
| buildCmd: opts.buildCmd, | |
| startCmd: opts.startCmd, | |
| db: Boolean(opts.db), | |
| redis: Boolean(opts.redis), | |
| redisDb: opts.redisDb, | |
| shareRedisWith: opts.shareRedisWith, | |
| appDir: opts.appDir, | |
| runAs: opts.runAs, | |
| writeDirs: opts.writeDir ?? [], | |
| env: opts.env ?? [] | |
| }); | |
| }); | |
| service.command("deploy <name>").description("git pull + c\xE0i dependencies + build + restart service").action(async (name) => cmdServiceDeploy(name)); | |
| service.command("set <name>").description("\u0111\u1ED5i DANH T\xCDNH/QUY\u1EC0N GHI c\u1EE7a service \u0111\xE3 t\u1EA1o (ch\u1EA1y b\u1EB1ng user app web, ho\u1EB7c quay v\u1EC1 user ri\xEAng)").option("--run-as <domain|name>", "chuy\u1EC3n sang ch\u1EA1y b\u1EB1ng user h\u1EC7 th\u1ED1ng c\u1EE7a app/service \u0111\xE3 c\xF3").option("--standalone", "quay v\u1EC1 user h\u1EC7 th\u1ED1ng RI\xCANG c\u1EE7a service (c\xF4 l\u1EADp ho\xE0n to\xE0n)").option("--write-dir <path>", "\u0111\u1EB7t l\u1EA1i danh s\xE1ch \u0111\u01B0\u1EDDng d\u1EABn \u0111\u01B0\u1EE3c GHI th\xEAm (l\u1EB7p l\u1EA1i \u0111\u01B0\u1EE3c, thay th\u1EBF danh s\xE1ch c\u0169)", (v, prev) => [...Array.isArray(prev) ? prev : [], v], []).option("--no-write-dir", "b\u1ECF h\u1EBFt \u0111\u01B0\u1EDDng d\u1EABn ghi th\xEAm").action( | |
| async (name, opts) => cmdServiceSet(name, { | |
| runAs: opts.runAs, | |
| standalone: Boolean(opts.standalone), | |
| // commander: --no-write-dir biến opts.writeDir thành false | |
| writeDirs: Array.isArray(opts.writeDir) ? opts.writeDir : [], | |
| clearWriteDirs: opts.writeDir === false | |
| }) | |
| ); | |
| service.command("remove <name>").description("g\u1EE1 background service kh\u1ECFi napp \u2014 ch\u1ECDn xo\xE1 m\xE3 ngu\u1ED3n / database (service systemd lu\xF4n b\u1ECB g\u1EE1)").option("-y, --yes", "kh\xF4ng h\u1ECFi x\xE1c nh\u1EADn (m\u1EB7c \u0111\u1ECBnh: GI\u1EEE m\xE3 ngu\u1ED3n + database)").option("--all", "xo\xE1 t\u1EA5t c\u1EA3: m\xE3 ngu\u1ED3n (+ user), database").option("--source", "xo\xE1 lu\xF4n m\xE3 ngu\u1ED3n v\xE0 user h\u1EC7 th\u1ED1ng c\u1EE7a service").option("--db", "xo\xE1 lu\xF4n database").action( | |
| async (name, opts) => cmdServiceRemove(name, { | |
| yes: Boolean(opts.yes), | |
| source: Boolean(opts.all || opts.source), | |
| database: Boolean(opts.all || opts.db) | |
| }) | |
| ); | |
| service.command("list").description("li\u1EC7t k\xEA c\xE1c background service \u0111ang qu\u1EA3n l\xFD").action(() => cmdServiceList()); | |
| service.command("restart <name>").description("kh\u1EDFi \u0111\u1ED9ng l\u1EA1i service").action((name) => cmdServiceRestart(name)); | |
| service.command("stop <name>").description("d\u1EEBng service").action((name) => cmdServiceStop(name)); | |
| service.command("start <name>").description("kh\u1EDFi \u0111\u1ED9ng service").action((name) => cmdServiceStart(name)); | |
| service.command("logs <name>").description("xem log c\u1EE7a service (qua journalctl)").option("-f, --follow", "theo d\xF5i log li\xEAn t\u1EE5c").option("-n, --lines <n>", "s\u1ED1 d\xF2ng log", (v) => parseInt(v, 10), 100).action((name, opts) => cmdServiceLogs(name, { follow: Boolean(opts.follow), lines: opts.lines })); | |
| service.command("env-set <name> <pairs...>").description("c\u1EADp nh\u1EADt bi\u1EBFn m\xF4i tr\u01B0\u1EDDng trong .env (d\u1EA1ng KEY=VALUE, c\xF3 th\u1EC3 truy\u1EC1n nhi\u1EC1u)").action((name, pairs) => cmdServiceEnvSet(name, pairs)); | |
| var domain = program2.command("domain").description("qu\u1EA3n l\xFD domain ph\u1EE5 (alias) g\u1EAFn v\xE0o m\u1ED9t app"); | |
| domain.command("add <appDomain> <alias>").description("th\xEAm domain ph\u1EE5 tr\u1ECF v\xE0o app").action((a, b) => cmdDomainAdd(a, b)); | |
| domain.command("remove <appDomain> <alias>").description("g\u1EE1 domain ph\u1EE5").action((a, b) => cmdDomainRemove(a, b)); | |
| domain.command("list <appDomain>").description("li\u1EC7t k\xEA domain c\u1EE7a m\u1ED9t app").action((a) => cmdDomainList(a)); | |
| var cert = program2.command("cert").description("qu\u1EA3n l\xFD SSL mi\u1EC5n ph\xED qua Let's Encrypt (certbot)"); | |
| cert.command("list").description("li\u1EC7t k\xEA t\u1EA5t c\u1EA3 ch\u1EE9ng ch\u1EC9").action(() => cmdCertList()); | |
| cert.command("status [domain]").description("xem tr\u1EA1ng th\xE1i ch\u1EE9ng ch\u1EC9 c\u1EE7a m\u1ED9t domain").action((domain2) => cmdCertStatus(domain2)); | |
| cert.command("issue <domain>").description("ph\xE1t h\xE0nh ch\u1EE9ng ch\u1EC9 SSL m\u1EDBi (ch\u1EA1y kh\xF4ng t\u01B0\u01A1ng t\xE1c)").option("--no-www", "kh\xF4ng bao g\u1ED3m www.<domain>").option("--email <email>", "email \u0111\u0103ng k\xFD Let's Encrypt (nh\u1EADn c\u1EA3nh b\xE1o h\u1EBFt h\u1EA1n; nh\u1EDB cho l\u1EA7n sau)").option("--register-without-email", "\u0111\u0103ng k\xFD KH\xD4NG email (kh\xF4ng khuy\u1EBFn ngh\u1ECB)").option("--no-redirect", "kh\xF4ng t\u1EF1 th\xEAm chuy\u1EC3n h\u01B0\u1EDBng HTTP -> HTTPS").option("--extra <domain...>", "domain ph\u1EE5 kh\xE1c c\u1EA7n \u0111\u01B0a v\xE0o c\xF9ng ch\u1EE9ng ch\u1EC9", (v, prev) => [...prev, v], []).action( | |
| (domain2, opts) => cmdCertIssue(domain2, { | |
| noWww: !opts.www, | |
| extra: opts.extra ?? [], | |
| email: opts.email, | |
| registerWithoutEmail: Boolean(opts.registerWithoutEmail), | |
| redirect: opts.redirect | |
| }) | |
| ); | |
| cert.command("renew [domain]").description("gia h\u1EA1n ch\u1EE9ng ch\u1EC9 (b\u1ECF tr\u1ED1ng domain \u0111\u1EC3 gia h\u1EA1n t\u1EA5t c\u1EA3)").option("--force", "bu\u1ED9c gia h\u1EA1n ngay c\u1EA3 khi ch\u01B0a \u0111\u1EBFn h\u1EA1n").action((domain2, opts) => cmdCertRenew(domain2, { force: Boolean(opts.force) })); | |
| cert.command("revoke <domain>").description("thu h\u1ED3i v\xE0 xo\xE1 ch\u1EE9ng ch\u1EC9").option("-y, --yes", "kh\xF4ng h\u1ECFi x\xE1c nh\u1EADn").action(async (domain2, opts) => cmdCertRevoke(domain2, { yes: Boolean(opts.yes) })); | |
| var db = program2.command("db").description("qu\u1EA3n l\xFD database MariaDB \u0111\u1ED9c l\u1EADp"); | |
| db.command("create <name>").description("t\u1EA1o database + user ri\xEAng").option("--user <user>", "t\xEAn user CSDL (m\u1EB7c \u0111\u1ECBnh tr\xF9ng t\xEAn database)").action((name, opts) => cmdDbCreate(name, opts.user)); | |
| db.command("drop <name>").description("xo\xE1 database").option("-y, --yes", "kh\xF4ng h\u1ECFi x\xE1c nh\u1EADn").option("--user <user>", "xo\xE1 lu\xF4n user CSDL n\xE0y").action(async (name, opts) => cmdDbDrop(name, { yes: Boolean(opts.yes), user: opts.user })); | |
| db.command("list").description("li\u1EC7t k\xEA database").action(() => cmdDbList()); | |
| db.command("backup <name>").description("dump database ra file .sql.gz").action((name) => cmdDbBackup(name)); | |
| var redis = program2.command("redis").description("qu\u1EA3n l\xFD Redis d\xF9ng chung"); | |
| redis.command("info").description("xem INFO memory c\u1EE7a Redis").action(() => cmdRedisInfo()); | |
| redis.command("allocations").description("xem c\u1EA5p ph\xE1t Redis DB (0-15) cho t\u1EEBng app").action(() => cmdRedisAllocations()); | |
| redis.command("flush <dbIndex>").description("xo\xE1 to\xE0n b\u1ED9 d\u1EEF li\u1EC7u trong m\u1ED9t Redis DB index").option("-y, --yes", "kh\xF4ng h\u1ECFi x\xE1c nh\u1EADn").action(async (dbIndex, opts) => cmdRedisFlush(parseInt(dbIndex, 10), { yes: Boolean(opts.yes) })); | |
| var backup = program2.command("backup").description("sao l\u01B0u database + m\xE3 ngu\u1ED3n \u0111\u1ECBnh k\u1EF3"); | |
| backup.command("run").description("ch\u1EA1y backup ngay (file n\xE9n gzip)").addOption(new Option("--target <target>", "ph\u1EA1m vi backup").choices(["db", "files", "all"]).default("all")).option("--database <name>", "ch\u1EC9 backup m\u1ED9t database c\u1EE5 th\u1EC3 (m\u1EB7c \u0111\u1ECBnh: t\u1EA5t c\u1EA3)").option("--keep-days <n>", "retention: gi\u1EEF backup trong N ng\xE0y", (v) => parseInt(v, 10), DEFAULT_RETENTION_DAYS).option("--keep <n>", "(tu\u1EF3 ch\u1ECDn) gi\u1EEF t\u1ED1i \u0111a N b\u1EA3n g\u1EA7n nh\u1EA5t b\u1EA5t k\u1EC3 ng\xE0y", (v) => parseInt(v, 10)).option("--quiet", "gi\u1EA3m log (d\xF9ng khi ch\u1EA1y t\u1EEB systemd timer)").action( | |
| (opts) => cmdBackupRun({ | |
| target: opts.target, | |
| database: opts.database, | |
| keepDays: opts.keepDays, | |
| keepCount: opts.keep, | |
| quiet: Boolean(opts.quiet) | |
| }) | |
| ); | |
| backup.command("schedule").description("l\xEAn l\u1ECBch backup h\xE0ng ng\xE0y qua systemd timer").option("--time <HH:MM>", "gi\u1EDD ch\u1EA1y h\xE0ng ng\xE0y", "03:00").option("--keep-days <n>", "retention: gi\u1EEF backup trong N ng\xE0y", (v) => parseInt(v, 10), DEFAULT_RETENTION_DAYS).addOption(new Option("--target <target>", "ph\u1EA1m vi backup").choices(["db", "files", "all"]).default("all")).action((opts) => cmdBackupSchedule({ time: opts.time, keepDays: opts.keepDays, target: opts.target })); | |
| backup.command("unschedule").description("g\u1EE1 l\u1ECBch backup t\u1EF1 \u0111\u1ED9ng").action(() => cmdBackupUnschedule()); | |
| backup.command("list").description("li\u1EC7t k\xEA c\xE1c b\u1EA3n backup hi\u1EC7n c\xF3").action(() => cmdBackupList()); | |
| var firewall = program2.command("firewall").description("qu\u1EA3n l\xFD t\u01B0\u1EDDng l\u1EEDa UFW"); | |
| firewall.command("sync").description("\u0111\u1ED3ng b\u1ED9 UFW: deny m\u1EB7c \u0111\u1ECBnh, allow SSH, m\u1EDF 80/443 cho m\u1ECDi IP").option("--ssh-port <port>", "c\u1ED5ng SSH (m\u1EB7c \u0111\u1ECBnh: t\u1EF1 d\xF2 t\u1EEB sshd_config)", (v) => parseInt(v, 10)).option("--restrict-cloudflare", "(n\xE2ng cao) kho\xE1 origin: 80/443 CH\u1EC8 nh\u1EADn t\u1EEB d\u1EA3i IP Cloudflare (kh\xF4ng c\u1EA7n cho vi\u1EC7c l\u1EA5y IP client th\u1EADt)").option("--extra-port <port...>", "c\u1ED5ng c\xF4ng khai b\u1ED5 sung", (v, prev) => [...prev, parseInt(v, 10)], []).option("-y, --yes", "kh\xF4ng h\u1ECFi x\xE1c nh\u1EADn").action( | |
| async (opts) => cmdFirewallSync({ | |
| sshPort: opts.sshPort, | |
| restrictToCloudflare: Boolean(opts.restrictCloudflare), | |
| extraPorts: opts.extraPort ?? [], | |
| yes: Boolean(opts.yes), | |
| quiet: false | |
| }) | |
| ); | |
| firewall.command("status").description("xem tr\u1EA1ng th\xE1i UFW").action(() => cmdFirewallStatus()); | |
| var fail2ban = program2.command("fail2ban").description("qu\u1EA3n l\xFD fail2ban"); | |
| fail2ban.command("setup").description("\xE1p c\u1EA5u h\xECnh jail cho sshd + nginx + napp-ratelimit").option("--ssh-port <port>", "c\u1ED5ng SSH (m\u1EB7c \u0111\u1ECBnh: t\u1EF1 d\xF2)", (v) => parseInt(v, 10)).action((opts) => cmdFail2banSetup({ sshPort: opts.sshPort })); | |
| fail2ban.command("status").description("xem tr\u1EA1ng th\xE1i c\xE1c jail").action(() => cmdFail2banStatus()); | |
| fail2ban.command("unban <jail> <ip>").description("g\u1EE1 ch\u1EB7n m\u1ED9t IP kh\u1ECFi jail").action((jail, ip) => cmdFail2banUnban(jail, ip)); | |
| var tune = program2.command("tune").description("t\u1ED1i \u01B0u nginx/MariaDB/Redis/sysctl theo ph\u1EA7n c\u1EE9ng th\u1EF1c t\u1EBF"); | |
| tune.command("show").description("xem ph\u1EA7n c\u1EE9ng ph\xE1t hi\u1EC7n \u0111\u01B0\u1EE3c + k\u1EBF ho\u1EA1ch t\u1ED1i \u01B0u (ch\u01B0a \xE1p d\u1EE5ng)").action(() => cmdTuneShow()); | |
| tune.command("apply").description("\xE1p c\u1EA5u h\xECnh t\u1ED1i \u01B0u \u2014 ch\u1EA1y l\u1EA1i b\u1EA5t c\u1EE9 khi n\xE0o n\xE2ng c\u1EA5p ph\u1EA7n c\u1EE9ng server").option("--db-ram-percent <n>", "ghi \u0111\xE8 % RAM d\xE0nh cho InnoDB buffer pool", (v) => parseInt(v, 10)).option("-y, --yes", "kh\xF4ng h\u1ECFi x\xE1c nh\u1EADn").option("--skip-restart", "ch\u1EC9 ghi file c\u1EA5u h\xECnh, kh\xF4ng restart service").option( | |
| "--sync-units", | |
| "render l\u1EA1i TO\xC0N B\u1ED8 unit systemd t\u1EEB template (\u0111\u1ED3ng b\u1ED9 hardening m\u1EDBi xu\u1ED1ng unit c\u0169). M\u1EB7c \u0111\u1ECBnh ch\u1EC9 s\u1EEDa \u0111\xFAng c\xE1c d\xF2ng c\u1EA7n s\u1EEDa (--max-old-space-size, CPUWeight, IOWeight, MemoryHigh); directive b\u1EA1n s\u1EEDa tay v\u1EABn \u0111\u01B0\u1EE3c gi\u1EEF trong c\u1EA3 hai ch\u1EBF \u0111\u1ED9" | |
| ).option( | |
| "--service-weight <n>", | |
| `ph\u1EA7n heap c\u1EE7a background service so v\u1EDBi web app, 0.1\u20131 (m\u1EB7c \u0111\u1ECBnh ${SERVICE_WEIGHT_DEFAULT} = web app g\u1EA5p \u0111\xF4i worker; 1 = chia \u0111\u1EC1u nh\u01B0 tr\u01B0\u1EDBc 1.25.0). Gi\xE1 tr\u1ECB \u0111\u01B0\u1EE3c L\u01AFU n\xEAn m\u1ECDi l\u1EA7n t\u1EA1o/xo\xE1 app sau v\u1EABn gi\u1EEF \u0111\xFAng t\u1EF7 l\u1EC7`, | |
| (v) => parseFloat(v) | |
| ).action( | |
| async (opts) => cmdTuneApply({ | |
| dbRamPercent: opts.dbRamPercent, | |
| serviceWeight: opts.serviceWeight, | |
| yes: Boolean(opts.yes), | |
| skipRestart: Boolean(opts.skipRestart), | |
| syncUnits: Boolean(opts.syncUnits) | |
| }) | |
| ); | |
| var cloudflare = program2.command("cloudflare").description("\u0111\u1ED3ng b\u1ED9 Cloudflare"); | |
| cloudflare.command("sync").description("\u0111\u1ED3ng b\u1ED9 d\u1EA3i IP Cloudflare v\xE0o nginx \u0111\u1EC3 tr\xEDch xu\u1EA5t \u0111\xFAng IP client th\u1EADt").option("--quiet", "gi\u1EA3m log").action((opts) => cmdCloudflareSync({ quiet: Boolean(opts.quiet) })); | |
| cloudflare.command("schedule").description("l\xEAn l\u1ECBch t\u1EF1 \u0111\u1ED9ng \u0111\u1ED3ng b\u1ED9 IP Cloudflare v\xE0o nginx (systemd timer, h\xE0ng ng\xE0y)").option("--time <HH:MM>", "gi\u1EDD ch\u1EA1y h\xE0ng ng\xE0y", "01:00").action((opts) => cmdCloudflareSchedule({ time: opts.time })); | |
| cloudflare.command("unschedule").description("g\u1EE1 l\u1ECBch t\u1EF1 \u0111\u1ED9ng \u0111\u1ED3ng b\u1ED9 IP Cloudflare").action(() => cmdCloudflareUnschedule()); | |
| var nginx = program2.command("nginx").description("c\u1EA5u h\xECnh proxy d\xF9ng chung + hardening nginx"); | |
| nginx.command("harden").description("ch\u1EB7n truy c\u1EADp th\u1EB3ng IP / Host l\u1EA1 (default_server tr\u1EA3 444) + \u1EA9n phi\xEAn b\u1EA3n nginx").action(() => cmdNginxHarden()); | |
| nginx.command("unharden").description("g\u1EE1 c\u1EA5u h\xECnh hardening nginx (kh\xF4i ph\u1EE5c h\xE0nh vi m\u1EB7c \u0111\u1ECBnh)").action(() => cmdNginxUnharden()); | |
| nginx.command("sync").description( | |
| "\xE1p c\u1EA5u h\xECnh d\xF9ng chung cho vhost \u0110\xC3 C\xD3: b\u1ED9 \u0111\u1EC7m \u0111\u1EE7 cho route SvelteKit s\xE2u (h\u1EBFt 502), header Connection/WebSocket, v\xE0 ch\xE8n d\xF2ng include file location v\xE0o vhost t\u1EA1o b\u1EB1ng b\u1EA3n napp c\u0169 (ch\u01B0a c\xF3 -> m\u1ECDi c\u1EA5u h\xECnh napp ghi ra \u0111\u1EC1u kh\xF4ng t\u1EDBi \u0111\u01B0\u1EE3c site \u0111\xF3) \u2014 gi\u1EEF nguy\xEAn SSL c\u1EE7a certbot" | |
| ).action(() => cmdNginxSync()); | |
| nginx.command("scanblock").description("ch\u1EB7n qu\xE9t l\u1ED7 h\u1ED5ng CMS/framework PHP (.php, /wp-admin/, /phpmyadmin/, /cgi-bin/ -> 444 + log ri\xEAng), \xE1p cho M\u1ECCI site k\u1EC3 c\u1EA3 app t\u1EA1o b\u1EB1ng b\u1EA3n napp c\u0169").action(() => cmdNginxScanBlock()); | |
| nginx.command("unscanblock").description("t\u1EAFt ch\u1EB7n qu\xE9t l\u1ED7 h\u1ED5ng tr\xEAn to\xE0n m\xE1y (m\u1ED9t site ri\xEAng l\u1EBB: napp app set <domain> --no-scan-block)").action(() => cmdNginxUnscanBlock()); | |
| var mem = program2.command("mem").description("theo d\xF5i b\u1ED9 nh\u1EDB, ph\xE1t hi\u1EC7n r\xF2 r\u1EC9 s\u1EDBm, ch\u1EE5p heap snapshot"); | |
| mem.command("status").description("b\u1ED9 nh\u1EDB hi\u1EC7n t\u1EA1i, s\u1ED1 l\u1EA7n systemd \xE2m th\u1EA7m kh\u1EDFi \u0111\u1ED9ng l\u1EA1i, v\xE0 k\u1EBFt lu\u1EADn xu h\u01B0\u1EDBng c\u1EE7a t\u1EEBng \u0111\u01A1n v\u1ECB").action(() => cmdMemStatus()); | |
| mem.command("trend").description("xu h\u01B0\u1EDBng b\u1ED9 nh\u1EDB t\u1EEB d\u1EEF li\u1EC7u \u0111\xE3 l\u1EA5y m\u1EABu (c\u1EA7n \xEDt nh\u1EA5t 6 gi\u1EDD)").action(() => cmdMemTrend()); | |
| mem.command("watch").description("b\u1EADt l\u1EA5y m\u1EABu b\u1ED9 nh\u1EDB \u0111\u1ECBnh k\u1EF3 qua systemd timer \u2014 \u0110\xC2Y l\xE0 th\u1EE9 cho bi\u1EBFt c\xF3 r\xF2 r\u1EC9 TR\u01AF\u1EDAC khi app ch\u1EBFt").option("--interval <ph\xFAt>", "kho\u1EA3ng c\xE1ch gi\u1EEFa hai l\u1EA7n l\u1EA5y m\u1EABu (m\u1EB7c \u0111\u1ECBnh 15)", (v) => parseInt(v, 10), 15).action((opts) => cmdMemWatch({ interval: opts.interval })); | |
| mem.command("unwatch").description("t\u1EAFt l\u1EA5y m\u1EABu \u0111\u1ECBnh k\u1EF3 (d\u1EEF li\u1EC7u c\u0169 v\u1EABn gi\u1EEF)").action(() => cmdMemUnwatch()); | |
| mem.command("sample").description("l\u1EA5y m\u1ED9t m\u1EABu ngay b\xE2y gi\u1EDD (l\u1EC7nh m\xE0 timer ch\u1EA1y)").option("--quiet", "kh\xF4ng in g\xEC khi th\xE0nh c\xF4ng").action((opts) => cmdMemSample({ quiet: Boolean(opts.quiet) })); | |
| mem.command("snapshot <app|service>").description("ch\u1EE5p heap snapshot c\u1EE7a ti\u1EBFn tr\xECnh \u0110ANG CH\u1EA0Y (app kh\xF4ng ch\u1EBFt) \u2014 c\u1EA7n b\u1EADt 'napp mem guard' tr\u01B0\u1EDBc").option("-y, --yes", "kh\xF4ng h\u1ECFi x\xE1c nh\u1EADn").action(async (id, opts) => cmdMemSnapshot(id, { yes: Boolean(opts.yes) })); | |
| mem.command("guard <app|service>").description("b\u1EADt c\u1EDD ch\u1EA9n \u0111o\xE1n r\xF2 r\u1EC9 (t\u1EF1 ch\u1EE5p heap tr\u01B0\u1EDBc khi OOM + cho ph\xE9p ch\u1EE5p theo y\xEAu c\u1EA7u). C\xD3 restart \u0111\u01A1n v\u1ECB").action((id) => cmdMemGuard(id, true)); | |
| mem.command("unguard <app|service>").description("t\u1EAFt c\u1EDD ch\u1EA9n \u0111o\xE1n r\xF2 r\u1EC9 (c\xF3 restart \u0111\u01A1n v\u1ECB)").action((id) => cmdMemGuard(id, false)); | |
| program2.command("update").description("t\u1EF1 c\u1EADp nh\u1EADt napp l\xEAn b\u1EA3n m\u1EDBi nh\u1EA5t (OTA qua gist)").action(() => cmdUpdate()); | |
| program2.command("version").description("in phi\xEAn b\u1EA3n hi\u1EC7n t\u1EA1i").action(() => cmdVersion()); | |
| program2.command("changelog").description("xem l\u1ECBch s\u1EED thay \u0111\u1ED5i").action(() => cmdChangelog()); | |
| program2.command("install").description("c\xE0i napp v\xE0o /usr/local/bin + banner ch\xE0o m\u1EEBng SSH").action(() => cmdInstallSelf()); | |
| program2.command("uninstall").description("g\u1EE1 napp kh\u1ECFi /usr/local/bin (c\xE1c app hi\u1EC7n c\xF3 v\u1EABn gi\u1EEF nguy\xEAn)").action(() => cmdUninstallSelf()); | |
| if (process.argv.length <= 2) { | |
| runMenu().catch((e) => { | |
| printDie(e.message); | |
| process.exit(1); | |
| }); | |
| } else { | |
| program2.exitOverride(); | |
| program2.parseAsync(process.argv).catch((e) => { | |
| if (e instanceof NappError) { | |
| printDie(e.message); | |
| process.exit(1); | |
| } | |
| if (e && typeof e === "object" && "code" in e) { | |
| process.exit(0); | |
| } | |
| printDie(e.message ?? String(e)); | |
| if (state.verbose) console.error(e); | |
| process.exit(1); | |
| }); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment