【排障系列】npm script 執行」丟失「 root 權限的問題

一、問題背景

近期,在線上運行服務時遇到了一個詭異的 Linux 權限問題:root 用戶在操做本該有權限的資源時,卻報了權限錯誤。html

報錯以下:node

Error: EACCES: permission denied, mkdir '/root/.pm2/logs'
    at Object.mkdirSync (fs.js:921:3)
    at mkdirpNativeSync (/home/web_server/project/node_modules/pm2/node_modules/mkdirp/lib/mkdirp-native.js:29:10)
    at Function.mkdirpSync [as sync] (/home/web_server/project/node_modules/pm2/node_modules/mkdirp/index.js:21:7)
    at module.exports.Client.initFileStructure (/home/web_server/project/node_modules/pm2/lib/Client.js:133:25)
    at new module.exports (/home/web_server/project/node_modules/pm2/lib/Client.js:38:8)
    at new API (/home/web_server/project/node_modules/pm2/lib/API.js:108:19)
    at Object.<anonymous> (/home/web_server/、project/node_modules/pm2/lib/binaries/CLI.js:22:11)
    at Module._compile (internal/modules/cjs/loader.js:1137:30)
    at Object.Module._extensions..js (internal/modules/cjs/loader.js:1157:10)
    at Module.load (internal/modules/cjs/loader.js:985:32)
複製代碼

這個錯誤很是直觀,就是用戶想要建立 /root/.pm2/logs 文件夾,可是沒有權限。該服務使用 pm2 作多進程管理。pm2 默認會將其日誌信息、進程信息等寫入到 $HOME/.pm2 下。由於是 root 後用戶因此寫到了 /root/.pm2 裏。linux

但這個問題的奇怪之處在於,服務是經過 root 用戶啓動的,對 /root 目錄是具備寫入權限的。但這裏卻報了名優權限的錯我。c++

那麼是什麼致使 root 用戶操做 /root 目錄的權限「丟失」了呢?git

二、初步排查

項目是容器化部署,使用 npm script 啓動,代碼文件位於 /home/web_server/project 下。執行 npm start 便可啓動。github

這是咱們使用的一套標準的構建與部署「模版」,已經在上百個服務上應用,且一直都正常。知道近期的一次上線出現了上面這個問題。web

此次忽然出現的這個問題讓我充滿了疑惑 —— 基於對 Linux 系統用戶、用戶組權限控制的理解,不可能出現這個錯誤。難道是我理解有誤?npm

在疑惑的同時,我嘗試不使用 npm script,直接經過 pm2 命令行 pm2 start ecosystem.config.js 啓動,發現服務正常啓動了!莫非是 npm 致使的?c#

而在此次上線的時候,確實更新了基礎鏡像,升級了 npm cli。以前是 v6.x,此次更新到了 v7.x。而當我將 npm 版本回退到 v6.x 後,問題小時。看來是 v7.x 的改動致使了這個問題。api

三、問題定位

先說結論:npm v6.x 使用 npm script 執行命令時默認會使用 unsafe 模式,將子執行命令的子進程設置爲 root 用戶/用戶組,該行爲能夠經過 unafe-pem 配置來控制。而在 v7.x 中,若是經過 root 用戶執行 npm script,則會基於當前目錄(cwd)所屬用戶來設置。

下面經過代碼來一塊兒看下。

3.一、v7.x 中 npm script 的實現

如下代碼來自 npm/cli v7.11.1

npm script 的執行邏輯能夠從 lib/exec.js 中查看:

class Exec extends BaseCommand {
  // ...
  async _exec (_args, { locationMsg, path, runPath }) {
    // ...
    if (call && _args.length)
      throw this.usage

    return libexec({
      ...flatOptions,
      args,
      // ...
    })
  }
}
複製代碼

省略無關代碼,能夠看到執行 npm script 時會調用 libexec 方法,libexec 方法內部會調用 runScript 方法來執行命令。

由於調用鏈比較長,我把中間代碼省略了,只貼出關鍵的代碼,感興趣的朋友能夠點擊文中連接跳轉查看。

經過一系列曲折的調用,代碼最後會調用到 promiseSpawn 方法。這個方法最終會使用 child_process 內置模塊裏提的 spawn 方法來啓動子進程執行命令,其相關代碼以下:

const promiseSpawn = (cmd, args, opts, extra = {}) => {
  const cwd = opts.cwd || process.cwd()
  const isRoot = process.getuid && process.getuid() === 0
  const { uid, gid } = isRoot ? inferOwner.sync(cwd) : {}
  return promiseSpawnUid(cmd, args, {
    ...opts,
    cwd,
    uid,
    gid
  }, extra)
}
複製代碼

上面的實現中,有一行很是重要:

const { uid, gid } = isRoot ? inferOwner.sync(cwd) : {}
複製代碼

能夠看到,若是當前進程的用戶是 root,則會使用 inferOwner 方法來設置啓動的子進程的 uid 和 gid(也就是用戶 id 和用戶組 id)。

那麼 inferOwner 是作什麼的呢?它其實就是用來獲取某個文件所屬的用戶與用戶組的:

const inferOwnerSync = path => {
  path = resolve(path)
  if (cache.has(path))
    return cache.get(path)

  const parent = dirname(path)

  let threw = true
  try {
    const st = fs.lstatSync(path)
    threw = false
    const { uid, gid } = st
    cache.set(path, { uid, gid })
    return { uid, gid }
  } finally {
    if (threw && parent !== path) {
      const owner = inferOwnerSync(parent)
      cache.set(path, owner)
      return owner // eslint-disable-line no-unsafe-finally
    }
  }
}
複製代碼

其中最重要的代碼是這幾行:

const st = fs.lstatSync(path)
// ...
const { uid, gid } = st
複製代碼

fs.lstatSync 方法 會使用 fstat 這個系統調用來獲取文件的 uid 和 gid。

image.png

promiseSpawn 中會將 cwd 傳入來獲取 uid 和 gid。而在咱們線上服務的容器裏,咱們是在 /home/web_server/project 下執行 npm start,該目錄所屬用戶是 web_server,用戶組是 web_server。因此 npm 在啓動子進程時「切換」了用戶。

因此實際狀況是,pm2 start ecosystemt.config.js 至關因而被 web_server 用戶啓動的,可是環境變量 $HOME 仍然是 /root。因此在 /root 中建立文件夾,天然就沒有權限。

3.二、v6.x 中 npm script 實現方式的區別

如下代碼來自 npm/cli v6.14.8

v7.x 爲了權限安全,作了上述操做,那麼 v6.x 如何呢?

v6.x 的 npm script 入口是 lib/run-script.js 文件

function run (pkg, wd, cmd, args, cb) {
  // ...
  chain(cmds.map(function (c) {
    // pass cli arguments after -- to script.
    if (pkg.scripts[c] && c === cmd) {
      pkg.scripts[c] = pkg.scripts[c] + joinArgs(args)
    }

    // when running scripts explicitly, assume that they're trusted.
    return [lifecycle, pkg, c, wd, { unsafePerm: true }]
  }), cb)
}
複製代碼

而其實際執行則須要從 lifecycle 方法中來找。上面這段代碼的最後一行還有一個很是重要的參數 { unsafePerm: true },以後會用到。

lifecycle 自己代碼並不複雜,主要就是參數調整,而後調用實際函數。和 uid、gid 實際的設置代碼是在 npm-lifecycle/index.js 中的 runCmd 裏:

function runCmd (note, cmd, pkg, env, stage, wd, opts, cb) {
  // ...
  var unsafe = opts.unsafePerm
  var user = unsafe ? null : opts.user
  var group = unsafe ? null : opts.group
  
  // ...
  if (unsafe) {
    runCmd_(cmd, pkg, env, wd, opts, stage, unsafe, 0, 0, cb)
  } else {
    uidNumber(user, group, function (er, uid, gid) {
      if (er) {
        er.code = 'EUIDLOOKUP'
        opts.log.resume()
        process.nextTick(dequeue)
        return cb(er)
      }
      runCmd_(cmd, pkg, env, wd, opts, stage, unsafe, uid, gid, cb)
    })
  }
}

// ...
function runCmd_ (cmd, pkg, env, wd, opts, stage, unsafe, uid, gid, cb_) {
  // ...
  var proc = spawn(sh, args, conf, opts.log)
  // ...
}
複製代碼

runCmd 裏會經過傳入的 opt.unsafePem 參數(就是上面設置的那個 { unsafePerm: true })來判斷是不是 unsafe 的。若是是 unsafe,則會在調用 runCmd_ 時將 uid、gid 設置爲 0。0 就表明 root 用戶和 root 用戶組。

而最終在 runCmd_ 中的 spawn 就是 child_process 中的 spawn 方法:

const _spawn = require('child_process').spawn
// ...
function spawn (cmd, args, options, log) {
  // ...
  const raw = _spawn(cmd, args, options)
  // ...
}
複製代碼

到這裏咱們就定位到了該問題:

  • 在 v6.x 中,只要沒有設置 unsafe-pem 這個 npm config,npm script 就會在啓動子進程時默認設置爲 root。
  • 而在 v7.x 中,若是運行時是 root 用戶,則會根據 cwd 所屬的用戶/用戶組,來設置啓動子進程的 uid 和 gid。

目前從代碼實現來看,彷佛沒有特別好的處理方式,比較簡答的兩種就是:

  • 若是用 v7.x,在咱們這個場景下,能夠把 /home/web_server/project 所屬用戶/用戶組改成 root。但權限的改動可能會引起其餘問題。
  • 先暫時回退到 v6.x,使環境和保持一致。

3.三、npm cli 的變動日誌

其實,這個變動在 npm v7.0.0-beta.0 發佈時的 CHANGELOG 裏是有提到的。不過只有寥寥一行:

The user, group, uid, gid, and unsafe-perms configurations are no longer relevant. When npm is run as root, scripts are always run with the effective uid and gid of the working directory owner.

大體說的就是我們上面從代碼分析的結論:若是是 root 運行 npm,則在腳本執行時切換到當前工做目錄的 owner。

而後若是你跟着代碼看下來,也會發現 v6.x 中的 unsafe-pem 配置,在 v7.0.0 開始就被廢棄了。不過 npm cli 文檔更新的較慢,直到 v7.0.0 正式版發佈後的一個月後,纔在 v7.0.15 的 Release 裏把 unsafe-pem 從文檔中移除。

3.四、其餘可能出現的問題

這個功能實現的變動,除了會致使一些文件操做時的權限問題,還會有一些其餘場景的權限錯誤。例如在若是你用 npm script 啓動一個 nodejs server,要綁定 443 端口,這個時候可能就會報錯。由於會須要 root 權限來執行這個端口綁定。在 issue 裏就有人提到了這個狀況


四、加餐:child_process#spawn 是如何設置 user 和 group 的?

經過上面的分析,問題已經被解決了。沿着這個問題,能夠具體看了下 Nodejs 中,child_process 模塊的 spawn 方法是如何設置 user 和 group 的。

如下代碼基於 Nodejs v14.16.1。只關注 unix 實現。

Nodejs 中,咱們在上層引入的模塊,是直接放在 lib 下面的,而其通常會在調用 lib/internal 下的對應模塊,這部分會直接使用 internalBinding 來調用 C++ 對象和方法。child_process 也不例外,你會在 lib/internal/child_process.js 中看到以下代碼:

ChildProcess.prototype.spawn = function(options) {
  // ...
  const err = this._handle.spawn(options);
  // ...
複製代碼

由於比較簡答,因此這裏省去了 lib/child_process.js 中的方法。只要知道,咱們在 JavaScript 層使用 spawn 方法時,最後會調用到 ChildProcess 實例的 spawn 方法便可。能夠看到最後是調用了 this._handle.spawn。那麼 this._handle 是什麼呢?

它其實就是經過 binding 建立的 Process 對象

const { Process } = internalBinding('process_wrap');

// ...
function ChildProcess() {
  EventEmitter.call(this);

  // ...
  this._handle = new Process();
  // ...
}
複製代碼

這個 binding 的設置在 src/process_wrap.cc 中,

static void Spawn(const FunctionCallbackInfo<Value>& args) {
    // ...

    // options.uid
    Local<Value> uid_v =
        js_options->Get(context, env->uid_string()).ToLocalChecked();
    if (!uid_v->IsUndefined() && !uid_v->IsNull()) {
      CHECK(uid_v->IsInt32());
      const int32_t uid = uid_v.As<Int32>()->Value();
      options.flags |= UV_PROCESS_SETUID;
      options.uid = static_cast<uv_uid_t>(uid);
    }

    // options.gid
    Local<Value> gid_v =
        js_options->Get(context, env->gid_string()).ToLocalChecked();
    if (!gid_v->IsUndefined() && !gid_v->IsNull()) {
      CHECK(gid_v->IsInt32());
      const int32_t gid = gid_v.As<Int32>()->Value();
      options.flags |= UV_PROCESS_SETGID;
      options.gid = static_cast<uv_gid_t>(gid);
    }
    
    int err = uv_spawn(env->event_loop(), &wrap->process_, &options);
    wrap->MarkAsInitialized();
    
    // ...
}
複製代碼

能夠看到,它把從 JavaScript 層設置的 uid 和 gid 設置到 options 上,而後調用了 uv_spawn 函數建立子進程。在 uv_spawn 中對於建立的子進程會經過 uv__process_child_init 來作初始化設置

int uv_spawn(uv_loop_t* loop, uv_process_t* process, const uv_process_options_t* options) {

    // ...
    if (pid == 0) {
        uv__process_child_init(options, stdio_count, pipes, signal_pipe[1]);
        abort();
    }
    // ...
}
複製代碼

最後則是在 uv__process_child_init 裏經過 setuidsetgid 這兩個系統調用來實現的:

static void uv__process_child_init(const uv_process_options_t* options, int stdio_count, int (*pipes)[2], int error_fd) {
    // ...
    if ((options->flags & UV_PROCESS_SETGID) && setgid(options->gid)) {
        uv__write_int(error_fd, UV__ERR(errno));
        _exit(127);
    }

    if ((options->flags & UV_PROCESS_SETUID) && setuid(options->uid)) {
        uv__write_int(error_fd, UV__ERR(errno));
        _exit(127);
    }
    // ...
}
複製代碼

在 Nodejs 官方文檔中也有介紹。

image.png

咱們經過閱讀代碼也印證了這一點。

完。

往期【排障系列】文章:

相關文章
相關標籤/搜索