WishMeLz

生活其实很有趣

Electron 主进程起一个可用的 HTTPS 静态服务器

1. 启动 HTTPS 服务

在 Electron 的主进程中,我们可以直接利用 Node.js 原生的 httphttps 模块来创建服务。区别在于 HTTPS 需要配置 key (私钥) 和 cert (证书)。

代码实现

import { createServer as createHttpServer } from "http";
import { createServer as createHttpsServer } from "https";
import { generateSiteCert } from "./cert-service"; // 下文会提到的证书服务

const startServer = async (config: ServerConfig) => {
  const requestHandler = (req, res) => {
    // 处理静态文件或 API 代理逻辑
    res.end("Hello Secure World!");
  };

  let server;
  
  if (config.https.enabled) {
    // 关键点:动态生成或读取站点证书
    // 返回的 key/cert 是 PEM 格式的字符串
    const { key, cert } = generateSiteCert(config.https.domain);
    
    server = createHttpsServer({ 
      key, 
      cert 
    }, requestHandler);
  } else {
    server = createHttpServer(requestHandler);
  }

  server.listen(config.port, () => {
    console.log(`Server running at ${config.https.enabled ? 'https' : 'http'}://localhost:${config.port}`);
  });
};

2. SSL 证书的自动化生成与认证

我们使用 node-forge 库来完成这些操作,它是一个纯 JavaScript 实现的加密库。

2.1 生成根证书 (Root CA)

CA 证书的有效期通常设置得很长(如 10 年),并且需要标记为 cA: true

import * as forge from "node-forge";

export const generateCA = () => {
  const keys = forge.pki.rsa.generateKeyPair(2048);
  const cert = forge.pki.createCertificate();
  cert.publicKey = keys.publicKey;
  cert.serialNumber = "01";
  
  // 设置有效期 10 年
  cert.validity.notBefore = new Date();
  cert.validity.notAfter = new Date();
  cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 10);

  const attrs = [
    { name: "commonName", value: `My Local Dev CA - ${Date.now()}` }, // 加上时间戳避免重名冲突
    { name: "organizationName", value: "Local Dev" }
  ];
  cert.setSubject(attrs);
  cert.setIssuer(attrs); // 自签名:颁发者等于使用者

  cert.setExtensions([
    { name: "basicConstraints", cA: true }, // 关键:标识为 CA
    { name: "keyUsage", keyCertSign: true, digitalSignature: true },
    { name: "subjectKeyIdentifier" } 
  ]);

  // 使用私钥签名
  cert.sign(keys.privateKey, forge.md.sha256.create());

  return {
    key: forge.pki.privateKeyToPem(keys.privateKey),
    cert: forge.pki.certificateToPem(cert)
  };
};

2.2 签发站点证书 (Site Certificate)

站点证书必须包含 SAN (Subject Alternative Name)Server Auth (扩展密钥用法),否则现代浏览器(Chrome 58+)会拦截。

export const generateSiteCert = (domain: string, caKeyPem: string, caCertPem: string) => {
  const caKey = forge.pki.privateKeyFromPem(caKeyPem);
  const caCert = forge.pki.certificateFromPem(caCertPem);

  const keys = forge.pki.rsa.generateKeyPair(2048);
  const cert = forge.pki.createCertificate();
  cert.publicKey = keys.publicKey;
  cert.serialNumber = Date.now().toString(); // 唯一的序列号
  
  // 有效期 1 年
  cert.validity.notBefore = new Date();
  cert.validity.notAfter = new Date();
  cert.validity.notAfter.setFullYear(cert.validity.notBefore.getFullYear() + 1);

  cert.setSubject([{ name: "commonName", value: domain }]);
  cert.setIssuer(caCert.subject.attributes); // 颁发者是我们的 CA

  cert.setExtensions([
    { name: "basicConstraints", cA: false },
    { 
      name: "keyUsage", 
      digitalSignature: true, 
      keyEncipherment: true 
    },
    {
      name: "extKeyUsage", // 关键:必须包含服务器验证
      serverAuth: true,
      clientAuth: true
    },
    {
      name: "subjectAltName", // 关键:SAN 字段
      altNames: [
        { type: 2, value: domain }, // DNS: mysite.local
        { type: 7, ip: "127.0.0.1" } // IP: 127.0.0.1
      ]
    },
    {
       name: "authorityKeyIdentifier", // 建立信任链的关键
       keyIdentifier: caCert.generateSubjectKeyIdentifier().data
    }
  ]);

  // 使用 CA 的私钥进行签名
  cert.sign(caKey, forge.md.sha256.create());

  return {
    key: forge.pki.privateKeyToPem(keys.privateKey),
    cert: forge.pki.certificateToPem(cert)
  };
};

2.3 信任根证书 (Windows)

生成的证书默认是不受系统信任的。我们需要调用 Windows 的 certutil 工具将我们的 CA 导入到“受信任的根证书颁发机构”存储区。

这需要管理员权限,我们可以通过 PowerShell 的 Start-Process -Verb RunAs 来触发 UAC 提权弹窗。

import { exec } from "child_process";

export const installCertTrust = (caCertPath: string) => {
  // certutil -addstore -user Root "path/to/rootCA.pem"
  const psCmd = `Start-Process certutil -ArgumentList '-addstore','-user','Root','"${caCertPath}"' -Verb RunAs -Wait`;
  
  return new Promise((resolve, reject) => {
    exec(`powershell "${psCmd}"`, (err) => {
      if (err) reject(err);
      else resolve();
    });
  });
};

3. 修改系统 Hosts 文件

为了支持 mysite.local 这样的自定义域名指向本地,我们需要修改系统的 Hosts 文件。

Windows 的 Hosts 文件位于 C:\Windows\System32\drivers\etc\hosts。直接写入通常会因为权限不足而失败(即使是管理员权限的 Node 进程有时也会受限)。

稳健的解决方案

  1. 读取原 Hosts 内容。
  2. 在内存中修改(添加 127.0.0.1 mysite.local)。
  3. 写入到一个临时文件。
  4. 使用提权 PowerShell 将临时文件强制复制覆盖系统 Hosts 文件。
import * as fs from "fs";
import * as path from "path";
import { app } from "electron"; // 获取临时目录路径

const HOSTS_PATH = "C:\\Windows\\System32\\drivers\\etc\\hosts";

export const updateHosts = (domain: string) => {
  // 1. 读取并修改内容
  let content = fs.readFileSync(HOSTS_PATH, "utf8");
  // ... (省略具体的字符串处理逻辑,如去重、追加) ...
  const newContent = content + `\r\n127.0.0.1 ${domain}`;

  // 2. 写入临时文件
  const tempPath = path.join(app.getPath("userData"), `hosts_temp_${Date.now()}`);
  fs.writeFileSync(tempPath, newContent);

  // 3. 提权覆盖
  // Copy-Item "temp" "hosts" -Force
  const psCmd = `Start-Process powershell -ArgumentList '-Command "Copy-Item ''${tempPath}'' ''${HOSTS_PATH}'' -Force"' -Verb RunAs -Wait`;

  return new Promise((resolve, reject) => {
    exec(`powershell "${psCmd}"`, (err) => {
      // 清理临时文件
      fs.unlinkSync(tempPath);
      
      if (err) reject(err);
      else resolve();
    });
  });
};

4. 总结

  1. Node-Forge 负责构建合规的证书链,骗过浏览器的安全检查。
  2. Certutil + PowerShell 负责打通操作系统的证书信任。
  3. Hosts Hack 负责实现自定义域名的本地回环解析。