Цель статьи - поделиться опытом, болью и поныть немного о проблемах. Никоим образом не ставилось целью показать, что именно такой путь правильный.
Как все начиналось
Задача - создать help раздел на базе движка wikijs.
Версия wikijs, которую брали за основу – 2.5.268
Крайняя версия, которую ставили (на момент написания статьи) – 2.5.274
Особенности и условия
исходный код править нельзя. Во первых, у нас в команде нет фронтендера, во вторых, непонятно как это потом поддерживать, когда будут выходить новые версии wikijs с новыми фичами
должны быть устранены критические уязвимости (CVE)
на внешнем фронте должна быть закрыта админка
на сборочных раннерах нет интернета, ну почти нет - некоторые ресурсы открыты через прокси по согласованию с ИБ
Основной упор был нацелен на устранение уязвимостей, а там их оказалось не мало.
Сначала было желание оповестить разработчика обо всех имеющихся критических уязвимостях, но почитав Security Policy (https://github.com/requarks/wiki/security/policy) и оценив скорость реакции на проблемы пользователей в Discussions, пришло понимание, что это плохой путь. Многие проблемы, озвученные в Discussions не решаются годами, хотя сам проект в целом активно развивается.
В Security Policy они хотят, чтобы по мимо известных CVE им присылали и примеры эксплоитов -:). Итак, было принято решение устранять уязвимости самим.
Для поиска существующих уязвимостей, использовали отчет Xray от JFrog, интегрированного с нашим docker registry.
Отчет XRAY
Для начала, надо понимать, что wikijs это гигантский комбайн с кучей всего, но весь функционал нам был не нужен, по этому, 1-й этап устранения уязвимостей – выкидывание не нужного. Каким образом удаляем не нужное – модифицируем package.json и пересобираем контейнер с приложением.
Что выкинули сразу
sqlite3 – не нужно от слова совсем
passport-microsoft – требуется для интеграции с azure (нам не нужно)
mssql – в топку
passport-azure-ad – azure нами не используется, удаляем
Следующий этап – апгрейд версий модулей, их зависимостей, а так же транзитивных зависимостей. Тут было много экспериментов. Если посмотрите файлик yarn.lock в исходном коде проекта – вам станет плохо. Там огромное количество транзитивных зависимостей. Чтобы было понимание о масштабах бедствия – этот текстовый файл имеет размер 6Mb!
Вторая большая боль – некоторые npm пакеты, используемые в данном проекте – давно не поддерживаются и имеют крайние обновления 3 – 5 лет назад, например: apollo-fetch - https://www.npmjs.com/package/apollo-fetch
А некоторые npm пакет и того пуще – deprecated, например graphql-tools версии 7.0.0 - https://www.npmjs.com/package/graphql-tools/v/7.0.0
Итак, после долгих экспериментов, что было сделано:
highlight.js – заменяем на версию 10.4.1
highlight.js присутствует также в зависимостях модуля diff2html. Повышаем версию diff2html до 3.4.14
i18next - повышаем версию до 19.8.5
js-yaml – повышаем версию до 3.14.1, также, это зависимость модуля i18next-node-fs-backend, разрешаем через resolutions: "i18next-node-fs-backend/js-yaml": "3.14.1"
sqlite-libs – непонятно из каких зависимостях они берутся и в каких слоях, но для верности удаляем целиком модуль sqlite3, так как нам он не нужен.
clean-css - повышаем до версии 5.2.2.
css-what – транзитивная зависимость модуля html-webpack-plugin – повышаем до версии 5.5.0 (css-what -> css-select -> renderkid -> pretty-error -> html-webpack-plugin)
xmldom – зависимость модуля passport-saml, повышаем версию последнего до 3.1.0
lodash – зависимость модуля passport-azure-ad, удаляем последний целиком
node-fetch – также одна из зависимостей passport-azure-ad
node-forge – транзитивная зависимость passport-saml, решаем вопрос через resolutions: "passport-saml/**/node-forge": "^1.0.0"
nanoid – повышаем до версии 3.1.31
ansi-regex 5.0.0 & 3.0.0 – залетают как зависимости пакетного менеджера npm вместе с образом nodejs 16.13.2. Поскольку, npm не учавствует в runtime на это можно закрыть глаза. Еще один вариант – сделать собственную сборку образа nodejs, в которую не ставить npm.
ansi-regex – транзитивная зависимость eslint & cypress, решаем через механизм resolutions: "eslint/**/ansi-regex": "^5.0.1", "cypress/**/ansi-regex": "^5.0.1"
underscore – зависимость пакетов express-brute & passport-cas, решаем вопрос через resolutions: "passport-cas/underscore": "^1.8.3", "express-brute/underscore": "^1.8.3"
node-uuid – зависимость модуля passport-cas, разрешаем через resolutions: "passport-cas/node-uuid": "1.4.8"
markdown-it – повышаем версию до 12.3.2
passport-oauth2 – зависимость модуля passport-microsoft, последний не обновляется уже 2 года и имеет уже крайнюю версию – удаляем целиком из package.json (нам не требуется)
ansi-regex 2.1.1 – транзитивная зависимость mssql (ansi-regex -> strip-ansi -> gauge -> npmlog -> prebuild-install -> keytar -> @azure/identity -> tedious -> mssql), удаляем целиком из package.json
Что не удалось
Дополнительные, транзитивные зависимости node-fetch -> apollo-fetch & cross-fetch -> graphql-tools. Модули apollo-fetch & cross-fetch давно не обновлялись (5 лет назад последний раз и имеют крайнюю версию). Попытка изменить версию node-fetch для apollo-fetch, приводит к ошибке.
error: require() of ES Module /wiki/node_modules/apollo-fetch/node_modules/node-fetch/src/index.js
from /wiki/node_modules/apollo-fetch/node_modules/cross-fetch/dist/node.js not supported.
Instead change the require of index.js in /wiki/node_modules/apollo-fetch/node_modules/cross-fetch/dist/node.js
to a dynamic import() which is available in all CommonJS modules.
Модули ansi-regex версий 5.0.0 & 3.0.0 - залетают как зависимости пакетного менеджера npm вместе с образом nodejs 16.13.2. Поскольку, npm не участвует в runtime, на это можно закрыть глаза. Еще один вариант – сделать собственную сборку образа nodejs, в которую не ставить npm.
/usr/local/lib/node_modules/npm/node_modules/string-width/node_modules/ansi-regex
/usr/local/lib/node_modules/npm/node_modules/ansi-regex
/usr/local/lib/node_modules/npm/node_modules/cli-table3/node_modules/ansi-regex
/usr/local/lib/node_modules/npm/node_modules/cli-columns/node_modules/ansi-regex
В модулях самого приложения, ansi-regex требуемой версии:
bash-5.1$ grep 'version' /wiki/node_modules/ansi-regex/package.json
"version": "5.0.1",
Ошибки в процессе сборки
warning Error running install script for optional dependency: "/wiki/node_modules/cpu-features: Command failed.
Exit code: 1
Command: node-gyp rebuild
Arguments:
Directory: /wiki/node_modules/cpu-features
Output:
gyp info it worked if it ends with ok
gyp info using node-gyp@8.3.0
gyp info using node@16.13.2 | linux | x64
gyp ERR! find Python
Это ошибки сборки некоторых опциональных зависимостей и это можно игнорировать.
info This module is OPTIONAL, you can safely ignore this error
Интерпретатор python не установлен в контейнере намеренно, о чем указывает вышеописанная ошибка: "gyp ERR! find Python". В противном случае, будет осуществлена попытка сборки опциональных зависимостей из исходников, а это приведет к ошибке в пайплайне, так как будет попытка скачать исходники с разных ресурсов в интернете, а интернета на раннерах нет.
Изменения в package.json
diff2html 3.4.14
i18next 19.8.5
js-yaml 3.14.1
sqlite3 5.0.2
chalk 4.1.2
passport-microsoft 0.1.0
mssql 6.2.3 - remove
passport-saml 3.1.0
highlight.js 10.4.1
passport-azure-ad 4.3.1
html-webpack-plugin 5.5.0
cheerio 1.0.0-rc.10
nanoid 3.1.31
markdown-it 12.3.2
clean-css 5.2.2
resolutions:
"i18next-node-fs-backend/js-yaml": "3.14.1"
"passport-cas/node-uuid": "1.4.8",
"passport-cas/underscore": "^1.8.3",
"express-brute/underscore": "^1.8.3",
"eslint/**/ansi-regex": "^5.0.1",
"cypress/**/ansi-regex": "^5.0.1
"passport-saml/**/node-forge": "^1.0.0"
На какие поля еще стоит обратить внимание в package.json
"version": | "2.5.274", |
"releaseDate" | "2022-01-29T18:45:51.000Z", |
"dev" | false, |
Версию релиза и дату необходимо будет поддерживать самим. Если в поле dev будет true - увидите сверху красную полосу в UI, что приложение находится в "Development mode".
Чего делать нельзя!
Апгрейдить версию chalk до мажорной 5 и выше
Error [ERR_REQUIRE_ESM]: require() of ES Module /wiki/node_modules/chalk/source/index.js from /wiki/server/core/config.js not supported.
Instead change the require of index.js in /wiki/server/core/config.js to a dynamic import() which is available in all CommonJS modules.
at Object.<anonymous> (/wiki/server/core/config.js:2:15)
at Object.<anonymous> (/wiki/server/index.js:17:14) {
code: 'ERR_REQUIRE_ESM'
}
Суть проблемы
Chalk 5 has changed to ESM. They provide a link to better understand what that means: Pure ESM. From chalk README: IMPORTANT: Chalk 5 is ESM. If you want to use Chalk with TypeScript or a build tool, you will probably want to use Chalk 4 for now.
Решение - понизить версию chalk до крайней мажорной ветки 4 - 4.1.2
Повышать версию js-yaml до 4-й мажорной и выше
>>> Unable to read configuration file! Did you create the config.yml file?
try {
appconfig = yaml.safeLoad(
cfgHelper.parseConfigValue(
fs.readFileSync(confPaths.config, 'utf8')
)
)
Решение без правки исходного кода - не повышать версию js-yaml до 4-й мажорной ветки. Крайняя версия в ветке 3 - 3.14.1
Решение с правкой исходных кодов - менять в исходниках appconfig = yaml.safeLoad на appconfig = yaml.Load
Повышать версию graphql-rate-limit-directive выше 1.3.0
2022-01-13T19:28:06.185Z [MASTER] info: Loading GraphQL Schema...
2022-01-13T19:28:06.189Z [MASTER] error: createRateLimitTypeDef is not a function
const { createRateLimitTypeDef } = require('graphql-rate-limit-directive')
// const { GraphQLUpload } = require('graphql-upload')
Решение - понизить версию graphql-rate-limit-directive до 1.2.1
Повышать версию graphql-tools выше 7.0.0
2022-01-13T20:02:47.802Z [MASTER] info: Loading GraphQL Schema...
This package has been deprecated and now it only exports makeExecutableSchema.
And it will no longer receive updates.
We recommend you to migrate to scoped packages such as @graphql-tools/schema, @graphql-tools/utils and etc.
Check out https://www.graphql-tools.com to learn what package you should use instead!
2022-01-13T20:02:48.574Z [MASTER] error: Class extends value undefined is not a constructor or null
Решение - не повышать версию graphql-tools выше 7.0.0
Апгрейдить версию apollo-server-express
2022-01-13T20:36:57.268Z [MASTER] error: You must `await server.start()` before
calling `server.applyMiddleware()`
Описание проблемы: This is a known bug with an open issue and a merged PR to fix it. For now, you can downgrade to apollo-server-express@^2 Пруфлинк
Решение - понижение версии apollo-server-express до 2.25.2
Одновременно ставить несколько модулей graphql разных версий, например через dependency & resolutions
2022-01-13T21:15:46.283Z [MASTER] error: Cannot use GraphQLObjectType "AnalyticsQuery" from another module or realm.
Ensure that there is only one instance of "graphql" in the node_modules
directory. If different versions of "graphql" are the dependencies of other
relied on modules, use "resolutions" to ensure only one version is installed.
https://yarnpkg.com/en/docs/selective-version-resolutions
Duplicate "graphql" modules cannot be used at the same time since different
versions may have different capabilities and behavior. The data from one
version used in the function from another could produce confusing and
spurious results.
Решение - в package.json в dependency & resolutions должны быть одинаковые версии модуля graphql
Апгрейдить модуль graphql до 16-й мажорной ветки
2022-01-13T21:49:51.999Z [MASTER] error: graphql@16 dropped long-deprecated support
for positional arguments, please pass an object instead.
Решение - понижение версии модуля graphql до 15.3.0
Как строили пайплайн со сборочным конвейером
Примерный план
клонирование проекта с исходным кодом и переход на ветку с номером релиза
скачивание архива со сбилдженным приложением. Из данного архива берутся assets & server/views.
формирование .npmrc & .yarnrc для подключения remote-npm
скачивание пакетов локализации из проекта wiki-localization.git
Особенности реализации
Сборка осуществляется на раннерах гитлаба с подключением удаленного репозитория npm (remote-npm).
Этап build (yarn build) исключен из докерфайла, так как требует сборки некоторых модулей из исходников и соответственно, для этого нужен интернет.
Артифакты этапа build - assets, копируются из архива, официально сбилдженного приложения из github.
В контейнер с приложением, дополнительно копируются файлы локализации из проекта wiki-localization.git (https://github.com/Requarks/wiki-localization)
Файл конфига немного модифицировали (config.yml)
config.yml
port: 3000
bindIP: 0.0.0.0
db:
type: $(DB_TYPE)
host: '$(DB_HOST)'
port: $(DB_PORT)
user: '$(DB_USER)'
pass: '$(DB_PASS)'
db: $(DB_NAME)
storage: $(DB_FILEPATH)
ssl: $(DB_SSL)
logLevel: $(LOGLEVEL)
ha: $(HA_ACTIVE)
offline: true
Описание процесса сборки
В качестве базового брали образ nodejs 16.13.2 alpine 3.15.
Стейдж build
Установка yarn
Копирование модифицированного package.json из данного проекта в рабочую директорию
Копирование .npmrc & .yarnrc в рабочую директорию
Сборка модулей и их зависимостей из списка dependencies
Стейдж release
Установка всех необходимых пакетов
Создание директорий
Копирование собранных модулей из стейджа build
Копирование папки servers из проекта с исходным кодом
Копирование assets & server/views из архива с официальной сборкой проекта
Копирование package.json
Копирование конфигурационного файла приложения config.yml
Копирование лицензионного соглашение (из офиц релиза)
Копирование файлов локализации
Dockerfile под катом
# ====================
# --- Build Assets ---
# ====================
FROM artifactory.domain.ru/docker/node:16.13.2-alpine3.15 AS build
RUN apk add yarn --no-cache
WORKDIR /wiki
COPY ./package.json ./package.json
COPY ./.yarnrc ./.yarnrc
COPY ./.npmrc ./.npmrc
RUN yarn cache clean
RUN yarn --frozen-lock --production --non-interactive --verbose
# ===============
# --- Release ---
# ===============
FROM artifactory.domain.ru/docker/node:16.13.2-alpine3.15 as release
LABEL ru.domain.wikijs.title="Open source Wiki software based on Node.js" \
ru.domain.wikijs.description="Updated image for Wiki.js." \
ru.domain.wikijs.responsible="someone@domain.ru"
RUN apk add --no-cache -u \
bash \
curl \
git \
openssh \
gnupg && \
mkdir -p /wiki && \
mkdir -p /logs && \
mkdir -p /wiki/data/sideload && \
chown -R node:node /wiki /logs
WORKDIR /wiki
COPY --chown=node:node ./assets ./assets
COPY --chown=node:node ./manifest.json ./assets/manifest.json
COPY --chown=node:node --from=build /wiki/node_modules ./node_modules
COPY --chown=node:node ./server ./server
COPY --chown=node:node ./server/views ./server/views
COPY --chown=node:node ./config.yml ./config.yml
COPY --chown=node:node ./package.json ./package.json
COPY --chown=node:node ./LICENSE ./LICENSE
COPY --chown=node:node ./wiki-localization/locales.json ./data/sideload/locales.json
COPY --chown=node:node ./wiki-localization/en.json ./data/sideload/en.json
COPY --chown=node:node ./wiki-localization/ru.json ./data/sideload/ru.json
USER node
EXPOSE 3000
CMD ["node", "server"]
На этом процесс сборки контейнера с приложением закончен.
Второй этап - доработка напильником
Для того чтобы запретить любую возможность входа в административную панель приложения снаружи, было принято решение поднять 2 фронта. Примерно это выглядит так:
внутренний фронт с админкой - крутится в корпоративной сети (одна реплика)
внешний фронт без админки - торчит наружу (2 реплики)
оба фронта смотрят в один бэк - в одну БД (HA Cluster Patroni)
Как закрывали админку на внешнем фронте – закрытием локаций с использованием nginx на ingress контроллере кубера, где деплоили приложение. Пример:
nginx.ingress.kubernetes.io/server-snippet: |
location ~* "^/(login|_userav|h|u|e|logout|register|verify|login-reset|\.well-known|healthz)" {
return 301 https://$server_name;
}
Вторая часть задачи по закрытию возможности авторизации - выпиливание кнопки авторизации с панели управления, удалением куска js кода с использованием subs_filter nginx на том же ingress контроллере. Пример:
nginx.ingress.kubernetes.io/configuration-snippet: |
proxy_set_header Accept-Encoding "";
subs_filter "return\[[^\]]+?href:\"\/login[^\]]+?\]\)\][,]1\)\]" "return[]" ro;
subs_filter_types text/html text/javascript application/javascript;
Что еще делали – онлайн замену реальных пользователей, на обезличенного Administrator. Эта инфа отображается на каждой страничках wiki – Дата изменения и username. Тут так же - subs_filter от nginx.
subs_filter "author-name=\"(.*?)\"" "author-name=\"Administrator\"" ro;
subs_filter "author-id=\"(\d+)\"" "author-id=\"0\"" ro;
Замена favicon, так же, через грязный хак. Ну нельзя штатными средствами это сделать.
subs_filter "(<meta name=\"msapplication-TileImage\" content=\"\/)_assets\/favicons\/mstile-150x150\.png(\">)" "$1new__favicon.ico$2" ro;
subs_filter "<link(.+?)assets\/favicons\/(.+?)>" "" rg;
Безопасность должна быть безопасной!
Наши апсеки узрели дырку в авторизации на локейшене /graphl. Целиком данный локейш выпилить нельзя - на нем реализован поиск контента. Пришлось применить lua скрипт по ограничению методов с просмотром тела запросов. Спасибо коллегам за помощь!
Магия nginx lua
location /graphql {
set $namespace "{{ namespace }}";
set $ingress_name "{{ app.name }}-ingress";
set $service_name "{{ app.name }}";
set $service_port "80";
set $location_path "/graphql";
set $global_rate_limit_exceeding n;
limit_except POST {
deny all;
}
rewrite_by_lua_block {
lua_ingress.rewrite({
force_ssl_redirect = false,
ssl_redirect = true,
force_no_ssl_redirect = false,
use_port_in_redirects = false,
global_throttle = { namespace = "", limit = 0, window_size = 0, key = { }, ignored_cidrs = { } },
})
balancer.rewrite()
plugins.run()
ngx.req.read_body()
local body = ngx.req.get_body_data()
if body then
local m = ngx.re.match(body, "(search|pages|localization)")
if m==nil then body="" end
body = ngx.re.gsub(body, "authentication", "wrong")
body = ngx.re.gsub(body, "mutation", "wrong")
end
ngx.req.set_body_data(body)
}
# be careful with `access_by_lua_block` and `satisfy any` directives as satisfy any
# will always succeed when there's `access_by_lua_block` that does not have any lua code doing `ngx.exit(ngx.DECLINED)`
# other authentication method such as basic auth or external auth useless - all requests will be allowed.
#access_by_lua_block {
#}
header_filter_by_lua_block {
lua_ingress.header()
plugins.run()
}
body_filter_by_lua_block {
plugins.run()
}
log_by_lua_block {
balancer.log()
monitor.call()
plugins.run()
}
port_in_redirect off;
set $balancer_ewma_score -1;
set $proxy_upstream_name "service-wikijs-80";
set $proxy_host $proxy_upstream_name;
set $pass_access_scheme $scheme;
set $pass_server_port $server_port;
set $best_http_host $http_host;
set $pass_port $pass_server_port;
set $proxy_alternative_upstream_name "";
client_max_body_size 1m;
proxy_set_header Host $best_http_host;
# Pass the extracted client certificate to the backend
# Allow websocket connections
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header X-Request-ID $req_id;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $remote_addr;
proxy_set_header X-Forwarded-Host $best_http_host;
proxy_set_header X-Forwarded-Port $pass_port;
proxy_set_header X-Forwarded-Proto $pass_access_scheme;
proxy_set_header X-Scheme $pass_access_scheme;
# Pass the original X-Forwarded-For
proxy_set_header X-Original-Forwarded-For $http_x_forwarded_for;
# mitigate HTTPoxy Vulnerability
# https://www.nginx.com/blog/mitigating-the-httpoxy-vulnerability-with-nginx/
proxy_set_header Proxy "";
# Custom headers to proxied server
proxy_connect_timeout 5s;
proxy_send_timeout 60s;
proxy_read_timeout 60s;
proxy_buffering off;
proxy_buffer_size 4k;
proxy_buffers 4 4k;
proxy_max_temp_file_size 1024m;
proxy_request_buffering on;
proxy_http_version 1.1;
proxy_cookie_domain off;
proxy_cookie_path off;
# In case of errors try the next upstream server before returning an error
proxy_next_upstream error timeout;
proxy_next_upstream_timeout 0;
proxy_next_upstream_tries 3;
proxy_pass http://upstream_balancer;
proxy_redirect off;
}
Интеграция с AD
Тут было несколько проблем
LDAP URL - Если указать ldaps://domain.ru:636, то возникала ошибка невозможности отрезолвить данный домен, при этом, сеть внутри контейнера работала нормально. Проблема в каком-то js модуле или методе. Ошибка воспроизводится 100% только в docker контейнере. Пришлось указывать тут IP PDC.
Проблема в понимании секции "Разрешить самостоятельную регистрацию/self-registration (https://docs.requarks.io/auth/ldap). Если не включать данную опцию и не прописывать «Назначить группу», то при авторизации нового пользователя получим «тыкву». Дело в том, что для полноценной работы, нужны локальные пользователи и локальная группа, которой будут даны определенные права. Если включить данную опцию, то при успешной LDAP авторизации, создадутся новые локальные пользователи, с правами группы, которая указана в данной настройке. Пользователи создадутся с именем = значению поля в AD, указанным в "Display Name Field Mapping". Либо необходимо заранее создать локальных пользователей, назначить им права, при этом имена пользователей должны совпадать со значением поля в AD указанного в "Display Name Field Mapping"
Известные проблемы
Приложение может быть размещено только на выделенном домене, например wiki.domain.com и не может быть размещено в subpath, т.е. www.domain.com/wiki Пруф https://github.com/Requarks/wiki/discussions/4656
Приложение пытается лезть в интернет, для скачки файлов локализации, по этому, если интернета нет на сервере, скачиваем пакеты локализации, подкладываем приложению (описано в dockerfile), а в конфиге config.yml прописываем offline: true
Невозможно штатными средствами заменить favicon (применяли вышеуказанный хак). Обсуждение https://github.com/Requarks/wiki/discussions/3371, https://js.wiki/feedback/p/favicon
Нельзя удалить/переименовать директории для медиафайлов (прям рука-лицо) https://feedback.js.wiki/wiki/p/delete-folders-in-the-image-file-manager
Нельзя глобально изменить дефолтный timezone (тут тоже рука-лицо). Пруф https://feedback.js.wiki/wiki/p/select-date-format-and-time-zone-for-the-entire-site. Пока только такой грязный хак: ALTER TABLE users ALTER COLUMN timezone SET DEFAULT 'Europe/Moscow '; По умолчанию пользователь получает timezone GMT-3 или что-то в этом духе.
Настройка через текстовый конфиг невозможна. Например, настройка интеграции с LDAP, настройка локализации, создание групп, пользователей и т.д. Настройка предполагается только через UI. Автоматизация? Не, не слышали. Ну либо применять вливание настроек через sql скрипт.
Некоторые вещи, например таблица стилей css, попадает в локальный файловый кэш на момент старта приложения. Этот кэш обновляется при изменении и сохранении контента страниц. Если несколько инстансов приложения (например несколько реплик в кубернетес для HA), то могут возникать такие коллизии - при логине в админку попадаешь на одно приложение в рамках сессии и после обновления контента, локальный кэш обновляется только у одного приложения. Далее если перейти на данный ресурс в браузере и понажимать F5 для обновления страницы, попадая на второй инстанс приложения, можно увидеть различия в оформлении, если оно менялось. Механизма управления локальным кэшем нет. По этому, сделали авторестарт внешних фронтов по расписанию (ночью).
Спрашивайте, может чего еще вспомню. А пока, пойду помою руки после nodejs.
Всем добра! -:)