diff --git a/README.md b/README.md index 667b97d..97ab2c6 100644 --- a/README.md +++ b/README.md @@ -8,27 +8,30 @@ Server module for [Hexo]. ## Installation -``` bash +```bash $ npm install hexo-server --save ``` ## Usage -``` bash +```bash $ hexo server ``` Option | Description | Default --- | --- | --- `-i`, `--ip` | Override the default server IP. | `::` when IPv6 is available, else `0.0.0.0` (note: in most systems, `::` also binds to `0.0.0.0`) -`-p`, `--port` | Override the default port. | 4000 -`-s`, `--static` | Only serve static files. | false -`-l`, `--log [format]` | Enable logger. Override log format. | false -`-o`, `--open` | Immediately open the server url in your default web browser. | false +`-p`, `--port` | Override the default port. | `4000` +`-s`, `--static` | Only serve static files. | `false` +`-l`, `--log [format]` | Enable logger. Override log format. | `false` +`-o`, `--open` | Immediately open the server url in your default web browser. | `false` +`-c`, `--cert` | Certificate path | `/path/to/cert.crt` +`-ck`, `--key` | Certificate key path | `/path/to/key.key` +`--ssl` | Enable SSL localhost. If `--cert` and `--key` are present, SSL will be enabled automatically. If `--cert` and `--key` are not present, but `--ssl` is present, Hexo will automatically generate a self-signed certificate. | `false` ## Options -``` yaml +```yaml server: port: 4000 log: false @@ -51,6 +54,69 @@ server: - **header**: Add `X-Powered-By: Hexo` header - **serveStatic**: Extra options passed to [serve-static](https://github.com/expressjs/serve-static#options) +## Generate self-certificate + +You can build your own OpenSSL from the official source: [https://openssl-library.org/source/](https://openssl-library.org/source/). + +### For Windows Users +You can download precompiled OpenSSL binaries for Windows from trusted sources like: +- [https://slproweb.com/products/Win32OpenSSL.html](https://slproweb.com/products/Win32OpenSSL.html) + +Make sure to install the version matching your system architecture (32-bit or 64-bit). + +Once installed, you can generate a self-signed certificate using the command line: + +### Default config + +```bash +openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout localhost.key -out localhost.crt +``` + +### Custom config + +```bash +openssl req -x509 -nodes -days 365 -newkey rsa:2048 -keyout localhost.key -out localhost.crt -config openssl.cnf +``` + +### `openssl.cnf` contents + +```conf +[ req ] +default_bits = 2048 +distinguished_name = req_distinguished_name +req_extensions = req_ext + +[ req_distinguished_name ] +countryName = Country Name (2 letter code) +countryName_default = ID +stateOrProvinceName = State or Province Name (full name) +stateOrProvinceName_default = East Java +localityName = Locality Name (eg, city) +localityName_default = Surabaya +organizationName = Organization Name (eg, company) +organizationName_default = WMI +organizationalUnitName = Organizational Unit Name (eg, section) +organizationalUnitName_default = Developer +commonName = Common Name (e.g. server FQDN or YOUR name) +commonName_default = dev.webmanajemen.com +commonName_max = 64 +emailAddress = Email Address +emailAddress_default = dimaslanjaka@gmail.com + +[ req_ext ] +subjectAltName = @alt_names + +[ alt_names ] +DNS.1 = dev.webmanajemen.com +DNS.2 = localhost +DNS.3 = 192.168.1.75 +DNS.4 = 127.0.0.1 +``` + +#### description + +- `alt_names` is your dev/localhost domain. (set on your `hosts` file) + ## License MIT diff --git a/index.js b/index.js deleted file mode 100644 index 9f366b4..0000000 --- a/index.js +++ /dev/null @@ -1,30 +0,0 @@ -/* global hexo */ - -'use strict'; - -hexo.config.server = Object.assign({ - port: 4000, - log: false, - // `undefined` uses Node's default (try `::` with fallback to `0.0.0.0`) - ip: undefined, - compress: false, - header: true -}, hexo.config.server); - -hexo.extend.console.register('server', 'Start the server.', { - desc: 'Start the server and watch for file changes.', - options: [ - {name: '-i, --ip', desc: 'Override the default server IP. Bind to all IP address by default.'}, - {name: '-p, --port', desc: 'Override the default port.'}, - {name: '-s, --static', desc: 'Only serve static files.'}, - {name: '-l, --log [format]', desc: 'Enable logger. Override log format.'}, - {name: '-o, --open', desc: 'Immediately open the server url in your default web browser.'} - ] -}, require('./lib/server')); - -hexo.extend.filter.register('server_middleware', require('./lib/middlewares/header')); -hexo.extend.filter.register('server_middleware', require('./lib/middlewares/gzip')); -hexo.extend.filter.register('server_middleware', require('./lib/middlewares/logger')); -hexo.extend.filter.register('server_middleware', require('./lib/middlewares/route')); -hexo.extend.filter.register('server_middleware', require('./lib/middlewares/static')); -hexo.extend.filter.register('server_middleware', require('./lib/middlewares/redirect')); diff --git a/lib/index.js b/lib/index.js new file mode 100644 index 0000000..c47d779 --- /dev/null +++ b/lib/index.js @@ -0,0 +1,33 @@ +/* global hexo */ + +'use strict'; + +hexo.config.server = Object.assign({ + port: 4000, + log: false, + // `undefined` uses Node's default (try `::` with fallback to `0.0.0.0`) + ip: undefined, + compress: false, + header: true +}, hexo.config.server); + +hexo.extend.console.register('server', 'Start the server.', { + desc: 'Start the server and watch for file changes.', + options: [ + {name: '-i, --ip', desc: 'Override the default server IP. Bind to all IP address by default.'}, + {name: '-p, --port', desc: 'Override the default port.'}, + {name: '-s, --static', desc: 'Only serve static files.'}, + {name: '-l, --log [format]', desc: 'Enable logger. Override log format.'}, + {name: '-o, --open', desc: 'Immediately open the server url in your default web browser.'}, + {name: '-c, --cert [path]', desc: 'SSL certificate path.'}, + {name: '-ck, --key [path]', desc: 'SSL private certificate path.'}, + {name: '-h, --ssl', desc: 'Enable SSL localhost. If --cert and --key is present, ssl will enabled automatically. If --cert and --key is not present, but --ssl is preset, default certificate will be applied.'} + ] +}, require('./server')); + +hexo.extend.filter.register('server_middleware', require('./middlewares/header')); +hexo.extend.filter.register('server_middleware', require('./middlewares/gzip')); +hexo.extend.filter.register('server_middleware', require('./middlewares/logger')); +hexo.extend.filter.register('server_middleware', require('./middlewares/route')); +hexo.extend.filter.register('server_middleware', require('./middlewares/static')); +hexo.extend.filter.register('server_middleware', require('./middlewares/redirect')); diff --git a/lib/mkcert.js b/lib/mkcert.js new file mode 100644 index 0000000..4fa0811 --- /dev/null +++ b/lib/mkcert.js @@ -0,0 +1,208 @@ +'use strict'; + +const path = require('path'); +const fs = require('fs'); +const { X509Certificate, createPrivateKey } = require('crypto'); +const { execSync } = require('child_process'); +const Log = console; +const MKCERT_VERSION = 'v1.4.4'; +const cacheDirectory = path.join(process.cwd(), 'node_modules/.cache/hexo-server-certificates'); +const keyPath = path.join(cacheDirectory, 'localhost-key.pem'); +const certPath = path.join(cacheDirectory, 'localhost.pem'); + +/** + * Returns the platform-specific mkcert binary name based on OS and architecture. + * + * @returns {string} The name of the mkcert binary file. + * @throws {Error} If the platform is not supported. + */ +function getBinaryName() { + const platform = process.platform; + const arch = process.arch === 'x64' ? 'amd64' : process.arch; + + if (platform === 'win32') { + return `mkcert-${MKCERT_VERSION}-windows-${arch}.exe`; + } + if (platform === 'darwin') { + return `mkcert-${MKCERT_VERSION}-darwin-${arch}`; + } + if (platform === 'linux') { + return `mkcert-${MKCERT_VERSION}-linux-${arch}`; + } + + throw new Error(`Unsupported platform: ${platform}`); +} + +/** + * Downloads the mkcert binary for the current platform and architecture. + * + * If the binary already exists in the cache directory, it returns the cached path. + * Otherwise, it downloads the binary, saves it to disk, sets executable permissions, + * and returns the binary path. + * + * @async + * @returns {Promise} The path to the downloaded or cached mkcert binary, or undefined if an error occurs. + */ +async function downloadBinary() { + try { + const binaryName = getBinaryName(); + const binaryPath = path.join(cacheDirectory, binaryName); + const downloadUrl = `https://github.com/FiloSottile/mkcert/releases/download/${MKCERT_VERSION}/${binaryName}`; + + // Fetch remote file size first + const headResponse = await fetch(downloadUrl, { method: 'HEAD' }); + + if (!headResponse.ok) { + throw new Error(`Failed to fetch file header. Status: ${headResponse.status}`); + } + + const remoteFileSize = parseInt(headResponse.headers.get('content-length'), 10); + + if (fs.existsSync(binaryPath)) { + const localStats = await fs.promises.stat(binaryPath); + // Fix file corruption + if (localStats.size === remoteFileSize) { + Log.info('Local mkcert binary is up-to-date, skipping download.'); + return binaryPath; + } + Log.info('Local mkcert binary size mismatch, re-downloading...'); + } else { + await fs.promises.mkdir(cacheDirectory, { recursive: true }); + } + + Log.info('Downloading mkcert package...'); + + const response = await fetch(downloadUrl); + + if (!response.ok || !response.body) { + throw new Error(`Download failed with status ${response.status}`); + } + + Log.info('Download response was successful, writing to disk'); + + const binaryWriteStream = fs.createWriteStream(binaryPath); + + await response.body.pipeTo( + new WritableStream({ + write(chunk) { + return new Promise((resolve, reject) => { + binaryWriteStream.write(chunk, error => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + }, + close() { + return new Promise((resolve, reject) => { + binaryWriteStream.close(error => { + if (error) { + reject(error); + return; + } + resolve(); + }); + }); + } + }) + ); + + await fs.promises.chmod(binaryPath, 0o755); + + return binaryPath; + } catch (err) { + Log.error('Error downloading mkcert:', err); + throw err; // Important to rethrow if you want callers to know the download failed + } +} + +/** + * @typedef {Object} SelfSignedCertificate + * @property {string} key - Path to the generated private key file. + * @property {string} cert - Path to the generated certificate file. + * @property {string} rootCA - Path to the root Certificate Authority (CA) certificate. + */ + +/** + * Creates a self-signed SSL certificate using mkcert. + * + * @async + * @param {string|string[]} [host] - Optional additional host to include in the certificate. + * @returns {Promise} The paths to key, cert, and rootCA, or undefined on error. + */ +async function createSelfSignedCertificate(host) { + try { + const binaryPath = await downloadBinary(); + if (!binaryPath) throw new Error('missing mkcert binary'); + + await fs.promises.mkdir(cacheDirectory, { recursive: true }); + + // Ensure host is always an array + let hostList = []; + if (Array.isArray(host)) { + hostList = host; + } else if (typeof host === 'string' && host.length > 0) { + hostList = [host]; + } + + if (fs.existsSync(keyPath) && fs.existsSync(certPath)) { + const cert = new X509Certificate(fs.readFileSync(certPath)); + const key = fs.readFileSync(keyPath); + + // Check the certificate for each host + for (const h of hostList) { + if (!cert.checkHost(h)) { + Log.warn(`Certificate is not valid for host: ${h}`); + } else { + Log.info(`Certificate is valid for host: ${h}`); + } + } + + if (cert.checkPrivateKey(createPrivateKey(key))) { + Log.info('Using already generated self signed certificate'); + const caLocation = execSync(`"${binaryPath}" -CAROOT`).toString().trim(); + Log.info(`CA location at ${caLocation}`); + + return { + key: keyPath, + cert: certPath, + rootCA: `${caLocation}/rootCA.pem` + }; + } + } + + // Download mkcert binary + downloadBinary(); + + Log.info('Attempting to generate self signed certificate. This may prompt for your password'); + + const defaultHosts = ['localhost', '127.0.0.1', '::1']; + const allHosts = [...defaultHosts, ...hostList.filter(h => !defaultHosts.includes(h))]; + + // Install certificate for all hosts + execSync(`"${binaryPath}" -install -key-file "${keyPath}" -cert-file "${certPath}" ${allHosts.join(' ')}`, { + stdio: 'ignore' + }); + + const caLocation = execSync(`"${binaryPath}" -CAROOT`).toString().trim(); + + if (!fs.existsSync(keyPath) || !fs.existsSync(certPath)) { + throw new Error('Certificate files not found'); + } + + Log.info(`CA Root certificate created in ${caLocation}`); + Log.info(`Certificates created in ${cacheDirectory}`); + + return { + key: keyPath, + cert: certPath, + rootCA: `${caLocation}/rootCA.pem` + }; + } catch (err) { + Log.error('Failed to generate self-signed certificate. Falling back to http.', err); + } +} + +module.exports = { createSelfSignedCertificate, getBinaryName, downloadBinary, cacheDirectory, keyPath, certPath }; diff --git a/lib/server-core.js b/lib/server-core.js new file mode 100644 index 0000000..9967cf9 --- /dev/null +++ b/lib/server-core.js @@ -0,0 +1,72 @@ +'use strict'; + +const connect = require('connect'); +const http = require('http'); +const https = require('https'); +const fs = require('fs'); +const { underline } = require('picocolors'); +const open = require('open'); +const { checkPort, startServer, formatAddress } = require('./server-utils'); + +/** + * Starts a local HTTP or HTTPS server for Hexo with optional live reload or static serving. + * + * @this {import('hexo')} + * @param {Object} args - CLI arguments. + * @param {boolean} [args.https] - Use HTTPS if true. + * @param {string} [args.key] - Path to SSL key (required for HTTPS). + * @param {string} [args.cert] - Path to SSL cert (required for HTTPS). + */ +async function serverCore(args) { + const app = connect(); + const { config, extend, load, watch, log, emit, unwatch } = this; + + const ip = args.i || args.ip || config.server.ip || undefined; + const port = parseInt(args.p || args.port || config.server.port || process.env.port, 10) || 4000; + const root = config.root; + const useHttps = !!(args.https || args.key || args.cert); + + let serverInstance; + if (useHttps) { + if (!args.key || !args.cert) { + throw new Error('SSL key and cert are required for HTTPS.'); + } + const sslOptions = { + key: fs.readFileSync(args.key), + cert: fs.readFileSync(args.cert) + }; + serverInstance = https.createServer(sslOptions, app); + } else { + serverInstance = http.createServer(app); + } + + try { + await checkPort(ip, port); + await extend.filter.exec('server_middleware', app, { context: this }); + + args.s || args.static ? await load() : await watch(); + + const server = await startServer(serverInstance, port, ip); + const addr = server.address(); + const url = formatAddress(ip || addr.address, addr.port, root, useHttps); + + log.info('Hexo is running at %s . Press Ctrl+C to stop.', underline(url)); + emit('server'); + + if (args.o || args.open) await open(url); + + return server; + } catch (err) { + if (err.code === 'EADDRINUSE') { + log.fatal(`Port ${port} has been used. Try another port instead.`); + } else if (err.code === 'EACCES') { + log.fatal(`Permission denied. You can't use port ${port}.`); + } + + unwatch(); + throw err; + } +} + +module.exports = serverCore; +module.exports.serverCore = serverCore; // For backward compatibility diff --git a/lib/server-utils.js b/lib/server-utils.js new file mode 100644 index 0000000..84cc15a --- /dev/null +++ b/lib/server-utils.js @@ -0,0 +1,70 @@ +'use strict'; + +const net = require('net'); +const Promise = require('bluebird'); + +/** + * Starts a given server and listens on the specified port and IP. + * + * @param {net.Server} server - The server instance to start. + * @param {number} port - The port number to listen on. + * @param {string} ip - The IP address to bind the server to. + * @returns {Promise} A Promise that resolves with the server once it's listening. + */ +function startServer(server, port, ip) { + return new Promise((resolve, reject) => { + server.listen(port, ip, resolve); + server.on('error', reject); + }).then(() => server); +} + +/** + * Checks whether a port can be used on the specified IP by attempting to bind a temporary server. + * + * @param {string} ip - The IP address to bind the test server to. + * @param {number} port - The port number to test. + * @returns {Promise} A Promise that resolves if the port is available, or rejects on error. + */ +function checkPort(ip, port) { + if (port > 65535 || port < 1) { + return Promise.reject(new RangeError(`Port number ${port} is invalid. Try a number between 1 and 65535.`)); + } + + const server = net.createServer(); + + return new Promise((resolve, reject) => { + server.once('error', reject); + server.once('listening', resolve); + server.listen(port, ip); + }).then(() => { server.close(); }); +} + +/** + * Formats a network address into a URL string. + * + * @param {string} ip - The IP address (e.g., '127.0.0.1', '::', '0.0.0.0'). + * @param {number} port - The port number. + * @param {string} root - The root path to append (e.g., '/', '/app'). + * @param {boolean} [useHttps=false] - Whether to use HTTPS instead of HTTP. + * @returns {string} The formatted URL. + */ +function formatAddress(ip, port, root, useHttps = false) { + let hostname = ip; + if (ip === '0.0.0.0' || ip === '::') { + hostname = 'localhost'; + } + // Fix IPV6 + if (hostname.includes(':')) { + hostname = `[${hostname}]`; + } + // Change protocol based on HTTPS or HTTP + const protocol = useHttps ? 'https' : 'http'; + const path = root.startsWith('/') ? root : `/${root}`; + return new URL(`${protocol}://${hostname}:${port}${path}`).toString(); +} + +module.exports = { + startServer, + checkPort, + formatAddress +}; diff --git a/lib/server.js b/lib/server.js index 2571d10..39044ef 100644 --- a/lib/server.js +++ b/lib/server.js @@ -1,84 +1,33 @@ 'use strict'; -const connect = require('connect'); -const http = require('http'); -const { underline } = require('picocolors'); -const Promise = require('bluebird'); -const open = require('open'); -const net = require('net'); - +const serverCore = require('./server-core'); +const { createSelfSignedCertificate, keyPath, certPath } = require('./mkcert'); + +/** + * Starts the appropriate server (HTTP or HTTPS) based on CLI arguments. + * + * @this {import('hexo')} + * @param {Record} args - CLI arguments. + * @param {boolean} [args.h] - Alias for `--https`, enables HTTPS mode. + * @param {boolean} [args.ssl] - Enables HTTPS mode. + * @param {string} [args.ck] - Alias for `--key`, path to SSL key. + * @param {string} [args.key] - Path to SSL key. + * @param {string} [args.c] - Alias for `--cert`, path to SSL certificate. + * @param {string} [args.cert] - Path to SSL certificate. + * @returns {Promise} The running HTTP/HTTPS server instance. + */ module.exports = function(args) { - const app = connect(); - const { config } = this; - const ip = args.i || args.ip || config.server.ip || undefined; - const port = parseInt(args.p || args.port || config.server.port || process.env.port, 10) || 4000; - const { root } = config; - - return checkPort(ip, port).then(() => this.extend.filter.exec('server_middleware', app, {context: this})).then(() => { - if (args.s || args.static) { - return this.load(); - } - - return this.watch(); - }).then(() => startServer(http.createServer(app), port, ip)).then(server => { - const addr = server.address(); - const addrString = formatAddress(ip || addr.address, addr.port, root); - - this.log.info('Hexo is running at %s . Press Ctrl+C to stop.', underline(addrString)); - this.emit('server'); - - if (args.o || args.open) { - open(addrString); - } - - return server; - }).catch(err => { - switch (err.code) { - case 'EADDRINUSE': - this.log.fatal(`Port ${port} has been used. Try other port instead.`); - break; - - case 'EACCES': - this.log.fatal(`Permission denied. You can't use port ${port}.`); - break; - } - - this.unwatch(); - throw err; - }); -}; - -function startServer(server, port, ip) { - return new Promise((resolve, reject) => { - server.listen(port, ip, resolve); - server.on('error', reject); - }).then(() => server); -} - -function checkPort(ip, port) { - if (port > 65535 || port < 1) { - return Promise.reject(new RangeError(`Port number ${port} is invalid. Try a number between 1 and 65535.`)); - } + const hasSSLArgs + = args.h || args.ssl + || (('ck' in args || 'key' in args) && ('c' in args || 'cert' in args)); - const server = net.createServer(); + if (hasSSLArgs) { + const key = args.ck || args.key || keyPath; + const cert = args.c || args.cert || certPath; - return new Promise((resolve, reject) => { - server.once('error', reject); - server.once('listening', resolve); - server.listen(port, ip); - }).then(() => { server.close(); }); -} - -function formatAddress(ip, port, root) { - let hostname = ip; - if (ip === '0.0.0.0' || ip === '::') { - hostname = 'localhost'; - } - - if (hostname.includes(':')) { - hostname = `[${hostname}]`; + createSelfSignedCertificate(); + return serverCore.call(this, { ...args, key, cert, https: true }); } - const path = root.startsWith('/') ? root : `/${root}`; - return new URL(`http://${hostname}:${port}${path}`).toString(); -} + return serverCore.call(this, {...args, https: false }); +}; diff --git a/package.json b/package.json index bed2f4e..785ec0e 100644 --- a/package.json +++ b/package.json @@ -2,7 +2,7 @@ "name": "hexo-server", "version": "3.0.0", "description": "Server module of Hexo.", - "main": "index", + "main": "lib/index.js", "scripts": { "eslint": "eslint .", "test": "mocha test/index.js", @@ -13,7 +13,8 @@ }, "files": [ "index.js", - "lib/" + "lib/", + "certificates/" ], "repository": "hexojs/hexo-server", "homepage": "https://hexo.io/",