АйТи Фреш
Главная / Статьи / DevOps и автоматизация
DevOps и автоматизация

Служба работает от владельца каталога, но не видит свой /home: как открыть один путь при ProtectHome

Автор: Семёнов Евгений Сергеевич, директор ООО «АйТи-Фреш» · · ~22 мин чтения
Служба работает от владельца каталога, но не видит свой /home: как открыть один путь при ProtectHome
Иллюстрация к статье «Служба работает от владельца каталога, но не видит свой /home: как открыть один путь при ProtectHome».

Служба стартует зелёной, работает от того самого пользователя, что владеет каталогом, права 0700 на месте — а в логе ENOENT на /home/exchange/out. Дежурный делает chmod -R 777, потом chown, потом запускает от root, и ничего не меняется. Разбираю, почему тут бессильны права UNIX, что именно ProtectHome делает с /home, /root и /run/user, как за три команды это диагностировать и как открыть службе ровно один каталог, не выключая защиту целиком.

Симптом: ls от того же пользователя работает, а служба — нет

Картина, которую я вижу раз в пару месяцев. Есть служба обмена, она работает под пользователем exchange, кладёт файлы в /home/exchange/out. Юнит запущен, active (running), никаких падений. В приложении — FileNotFoundError или Permission denied на каталоге, который стопроцентно существует. Заходишь на сервер, делаешь sudo -u exchange ls -la /home/exchange/out — каталог на месте, владелец правильный, права правильные. Логика подсказывает: значит, права всё-таки не те. И дальше начинается вредительство: chmod -R 777, chown -R, потом User=root в юните. Не помогает ничего, а дыра в правах остаётся в проде надолго.

Дело в том, что вы и служба смотрят на разные файловые системы. systemd умеет запускать юнит в отдельном mount namespace, и внутри этого namespace поверх /home примонтировано либо «ничего» (недоступный узел), либо пустая tmpfs. Права доступа тут вообще ни при чём: chmod меняет биты на объекте, которого процесс службы физически не видит. И root не спасает — ProtectHome прячет /home от процессов юнита независимо от того, кто в User=.

Отличить это от реальной проблемы с правами можно за десять секунд. Достаточно сравнить, что видит хост и что видит песочница с теми же настройками:

# что видит хост
sudo -u exchange ls -la /home/exchange/out

# что увидит служба с ProtectHome=yes
sudo systemd-run --pty -p ProtectHome=yes ls -la /home

# и с ProtectHome=tmpfs
sudo systemd-run --pty -p ProtectHome=tmpfs ls -la /home

Если первая команда показывает файлы, а вторая — пустой /home (от root) или «Permission denied» (если добавить -p User=exchange), а третья — пустую tmpfs, вопрос закрыт: это изоляция файловой системы, а не права. Дальше уже разговор не про chmod, а про то, какой именно каталог надо вернуть в поле зрения службы и каким ключом.

Если chmod 777 не вернул доступ — почти наверняка вы не в том слое. Верните права обратно и идите смотреть mount namespace, а не биты доступа.

Что именно делает ProtectHome и почему это не про права

ProtectHome= — ключ секции [Service], он появился ещё в systemd 214 (в man так и написано: Added in version 214), и базовая семантика значений с тех пор не менялась — в актуальной версии man-страницы описание то же, что в systemd 252 из Debian 12. Он принимает булево значение либо специальные значения read-only и tmpfs, и действует на три каталога: /home/, /root и /run/user. Именно на все три, а не только на /home — про /run/user ниже отдельный разговор, там прячется половина неочевидных поломок.

Формулировки в systemd.exec(5) предельно конкретные. При значении yes три каталога делаются недоступными и пустыми для процессов юнита. При read-only они остаются видимыми, но становятся доступными только на чтение. При tmpfs на них монтируются временные файловые системы в режиме только для чтения — и вот это значение, цитирую документацию, «полезно, чтобы скрыть домашние каталоги, не относящиеся к процессам юнита, при этом позволяя сделать видимыми нужные каталоги, перечисленные в BindPaths= или BindReadOnlyPaths=».

Там же дана шпаргалка по эквивалентности, которая экономит массу времени при разборе: yes — это по сути те же три каталога в InaccessiblePaths=, read-only — почти то же, что ReadOnlyPaths=, а tmpfs — почти то же, что TemporaryFileSystem= с суффиксом :ro. То есть ProtectHome не изобретает своего механизма, это удобная обёртка над обычными namespace-примитивами systemd, и ведёт себя она по их правилам.

И отдельный пункт, на котором спотыкаются чаще всего: настройка неявно включается, если задан DynamicUser=. Формально в вашем юните строки ProtectHome может не быть вообще — а фактически будет ProtectHome=read-only, потому что DynamicUser=yes подтягивает за собой ещё и ProtectSystem=strict, PrivateTmp= (если не задан явно), NoNewPrivileges=, RestrictSUIDSGID= и RemoveIPC=. Человек читает свой .service, не находит ProtectHome и делает вывод, что дело в другом. Дело именно в этом.

Про пользовательский менеджер (systemctl --user) формулировки зависят от версии: в man systemd 252 (Debian 12) сказано, что ключ доступен для системных служб, а в пользовательских инстансах — только при включённом PrivateUsers=, которому нужны непривилегированные user namespace в ядре. Прежде чем полагаться на ProtectHome в --user-юните, сверьтесь с man systemd.exec именно своей версии.
Служба работает от владельца каталога, но не видит свой /home: как открыть один путь при ProtectHome — схема
Схема к статье. Открыть схему в полном размере

Диагностика: три команды и вопрос закрыт

Первое правило: смотреть не свой .service, а эффективную конфигурацию. В половине случаев ProtectHome прилетает не из вашего файла, а из vendor-юнита пакета или из drop-in, который положил дистрибутив, конфигурационный менеджер или коллега полгода назад. systemctl cat показывает основной юнит и все drop-in подряд, systemctl show — итоговые значения после слияния.

# весь юнит целиком, включая drop-in из /usr/lib и /etc
systemctl cat exchange-agent.service

# итоговые значения после слияния всех фрагментов
systemctl show exchange-agent.service \
  -p ProtectHome -p ProtectSystem -p DynamicUser \
  -p BindPaths -p BindReadOnlyPaths \
  -p ReadWritePaths -p ReadOnlyPaths -p InaccessiblePaths

Второе — заглянуть внутрь живого namespace службы. Это железное доказательство, после которого спорить не о чем: вы буквально смотрите глазами процесса. Берём PID из systemctl show -p MainPID и идём внутрь.

PID=$(systemctl show -p MainPID --value exchange-agent.service)

# какие точки монтирования подсунуты юниту
grep -E ' /home| /root| /run/user' /proc/$PID/mountinfo

# посмотреть каталог глазами службы
sudo nsenter -t $PID -m -- ls -la /home /home/exchange

Третье — общий взгляд на песочницу. systemd-analyze security exchange-agent.service покажет таблицу применённых ограничений и итоговый уровень экспозиции по шкале 0.0–10.0, где высокие значения означают, что песочницы почти нет, а низкие — что она затянута туго. Инструмент удобен не столько оценкой, сколько списком: сразу видно, какие ключи реально включены, в том числе неявно. Только не воспринимайте цифру как метрику защищённости — документация прямо предупреждает, что оценивается лишь то, что реализует сам systemd, и что многие ограничения по отдельности обходятся.

Полезно различать и сами коды ошибок — они подсказывают, какой именно ключ сработал. «No such file or directory» на пути внутри /home почти всегда означает ProtectHome=tmpfs без нужного BindPaths= (поверх лежит пустая tmpfs, подкаталога в ней нет) или TemporaryFileSystem=. «Permission denied» при непривилегированном User= — ProtectHome=yes или InaccessiblePaths=: каталог заменён недоступным узлом. А «Read-only file system» (EROFS) при записи — это уже не ProtectHome=yes, а ProtectHome=read-only, ReadOnlyPaths= или ProtectSystem=strict, который делает всю файловую иерархию, кроме /dev, /proc и /sys, доступной только на чтение.

С ProtectSystem=strict правило такое: всё, куда служба пишет, перечисляется в ReadWritePaths= (или создаётся через StateDirectory= и соседние ключи — они сами попадают в список записываемых). Документация прямо разрешает вкладывать ReadWritePaths= внутрь ReadOnlyPaths=, чтобы получить записываемые подкаталоги, но вложить ReadWritePaths= или ReadOnlyPaths= внутрь InaccessiblePaths= нельзя — а ProtectHome=yes по смыслу и есть InaccessiblePaths= для /home, /root и /run/user. Поэтому ReadWritePaths=/home/exchange/out при ProtectHome=yes не «пробьёт» защиту, и разговор снова возвращается к tmpfs.

# какой ключ режет запись — смотрим журнал юнита и errno
journalctl -u exchange-agent.service -b --no-pager | grep -Ei 'denied|read-only|no such file|namespace'

# проверяем гипотезу во временном юните с теми же ключами
sudo systemd-run --pty -p User=exchange -p ProtectSystem=strict \
  -p ProtectHome=tmpfs -p BindPaths=/home/exchange/out \
  touch /home/exchange/out/probe
sudo -u <юзер> ls воспроизводит права, но не воспроизводит namespace. Для проверки песочницы годится только systemd-run с теми же ключами или nsenter в живой процесс.
Памятка: Диагностика: три команды и вопрос закрыт — схема
Памятка: Диагностика: три команды и вопрос закрыт. Открыть схему в полном размере

Как открыть ровно один каталог: tmpfs плюс BindPaths

Интуитивно хочется оставить ProtectHome=yes и просто прокинуть внутрь нужную папку через BindPaths=. Так не работает, и это описано в документации явно. Для BindPaths= и BindReadOnlyPaths= сказано: каталог назначения должен существовать или systemd должен иметь возможность его создать, поэтому «невозможно использовать эти опции для точек монтирования, вложенных в пути, указанные в InaccessiblePaths=, или под /home/ и другими защищёнными каталогами, если задан ProtectHome=yes. Вместо этого следует использовать TemporaryFileSystem= с ':ro' или ProtectHome=tmpfs». Ровно это и есть ответ на вопрос темы.

Практически это значит: меняем yes на tmpfs и перечисляем нужные пути. Поверх /home ложится пустая tmpfs, systemd создаёт в ней точки монтирования и прокидывает туда реальные каталоги. Все остальные домашки при этом остаются невидимыми — то есть защита не отключена, она стала точечной.

# /etc/systemd/system/exchange-agent.service.d/10-paths.conf
[Service]
ProtectHome=tmpfs

# рабочие каталоги обмена — на запись
BindPaths=/home/exchange/in
BindPaths=/home/exchange/out

# ключи и конфиг — только на чтение, «-» = не падать, если пути нет
BindReadOnlyPaths=-/home/exchange/.gnupg
BindReadOnlyPaths=-/home/exchange/.config/exchange

Синтаксис стоит запомнить целиком, он экономит нервы. Каждое определение — это разделённая двоеточиями тройка «источник:назначение:опция», причём назначение и опция необязательны; если указан только источник, назначение считается тем же путём. Опция — rbind или norbind, то есть рекурсивный или нерекурсивный bind; если опцию не указать, systemd делает рекурсивный bind (в исходниках парсера значение по умолчанию — rbind). Префикс «-» перед определением означает «игнорировать, если источника нет». BindPaths= даёт запись (если сама исходная ФС не смонтирована только для чтения), BindReadOnlyPaths= — только чтение. И ловушка: присвоение пустой строки любому из двух ключей сбрасывает оба списка сразу, и read-only, и обычный.

Есть и более простой вариант, который я применяю, когда служба должна видеть весь /home, но писать только в один подкаталог: ProtectHome=read-only плюс ReadWritePaths= на нужный путь. Документация прямо рекомендует такой приём — вкладывать ReadWritePaths= внутрь read-only-областей, чтобы получить записываемые подкаталоги. Это менее строго, чем tmpfs (чужие домашки остаются читаемыми), зато конфиг короче и предсказуемее.

ProtectHome=yes вместе с BindPaths=/home/... — это не «частично сработает»: документация прямо называет такую комбинацию невозможной. На практике юнит, как правило, не стартует с ошибкой настройки пространства имён (status=226/NAMESPACE в systemctl status). Меняйте yes на tmpfs либо уводите данные из /home.

Разбор: «Виниловый причал», выгрузка заказов встала после переезда на Debian 12

Магазин винила «Виниловый причал»: розничный зал, склад и интернет-витрина, 26 рабочих мест, свой сервер в нашей стойке. Переезжали с древней CentOS 7 (systemd 219) на Debian 12 с systemd 252. Среди прочего переезжал самописный агент обмена: 1С выгружает платёжные поручения поставщикам пластинок в /home/exchange/out, агент подписывает их ключом из /home/exchange/.gnupg и отдаёт в банк-клиент, а ответные выписки и статусы складывает в /home/exchange/in. Юнит работал от пользователя exchange, который и владел всеми этими каталогами.

При переезде юнит прогнали через типовой hardening-шаблон: ProtectSystem=strict, PrivateTmp=yes, NoNewPrivileges=yes, ProtectHome=yes, плюс десяток Restrict*-ключей. В 08:40 понедельника служба стартовала зелёной, в 08:44 бухгалтер магазина сообщила, что платежи поставщикам не уходят, а выписки за выходные не пришли. В журнале — обрыв на открытии /home/exchange/out. Дежурный сделал ровно то, что делают все: chmod -R 777 /home/exchange, потом chown -R exchange:exchange, потом убрал User= и запустил от root. Ноль эффекта — и это, к слову, отличный диагностический признак: если root не помог, дело не в правах.

Разбор занял шесть минут. systemctl show -p ProtectHome показал yes, grep ' /home' /proc/$PID/mountinfo — подмонтированный поверх узел, nsenter -t $PID -m -- ls /home — пустоту. Первый фикс был неверным: оставили ProtectHome=yes и добавили BindPaths=/home/exchange/out. Служба после этого перестала стартовать вовсе, падая с 226/NAMESPACE — ровно тот случай, который описан в документации как невозможный. Заменили yes на tmpfs, перечислили три пути, systemctl daemon-reload && systemctl restart exchange-agent.service — обмен пошёл.

sudo install -d /etc/systemd/system/exchange-agent.service.d
sudo tee /etc/systemd/system/exchange-agent.service.d/10-paths.conf >/dev/null <<'EOF'
[Service]
ProtectHome=tmpfs
BindPaths=/home/exchange/in
BindPaths=/home/exchange/out
BindReadOnlyPaths=-/home/exchange/.gnupg
EOF

sudo systemctl daemon-reload
sudo systemctl restart exchange-agent.service
sudo systemd-analyze security exchange-agent.service | head -20

Общий простой — 50 минут, из которых 35 ушло на chmod, chown и запуск от root. Осадок остался в другом: права 0777 на каталоге с платёжками и ключами провисели ещё трое суток, пока их не вычистили при плановой проверке. Через неделю мы довели дело до конца — вынесли рабочие каталоги в /var/lib/exchange через StateDirectory=, вернули ProtectHome=yes и убрали Bind-строки совсем. Уровень экспозиции по systemd-analyze security упал примерно с 9 (когда служба крутилась от root без песочницы) до 4 с небольшим. Цифру привожу как ориентир: она сильно зависит от полного набора ключей и от версии systemd, сравнивать её имеет смысл только «до и после» на одной машине.

Самый дорогой элемент этой аварии — не 50 минут простоя, а chmod 777 на каталоге с ключами подписи, о котором через сутки все забыли. Если что-то раздали в панике — записывайте это в задачу сразу же.
Цифры и версии: Разбор: «Виниловый причал», выгрузка заказов встала после переезда на Debian 12 — схема
Цифры и версии: Разбор: «Виниловый причал», выгрузка заказов встала после переезда на Debian 12. Открыть схему в полном размере

Где на самом деле должны лежать данные службы

Мой практический вывод после десятка таких разборов: если служба лезет в /home, это почти всегда наследство, а не архитектура. Домашние каталоги — для людей. Для служб в systemd давно есть штатный набор: RuntimeDirectory=, StateDirectory=, CacheDirectory=, LogsDirectory= и ConfigurationDirectory=. Каталоги создаются при старте юнита вместе с родителями, владельцем становится пользователь и группа из User=/Group=, а полный путь приезжает в окружение процесса переменными вида $STATE_DIRECTORY — не нужно даже хардкодить пути в коде.

Раскладка по системным юнитам такая: RuntimeDirectory= уходит в /run/, StateDirectory= — в /var/lib/, CacheDirectory= — в /var/cache/, LogsDirectory= — в /var/log/, ConfigurationDirectory= — в /etc/. Для пользовательских юнитов те же ключи ложатся в $XDG_RUNTIME_DIR, $XDG_STATE_HOME, $XDG_CACHE_HOME и $XDG_CONFIG_HOME. Важное отличие RuntimeDirectory= от остальных: его каталоги удаляются при остановке юнита (если не задан RuntimeDirectoryPreserve=), а StateDirectory=, CacheDirectory=, LogsDirectory= и ConfigurationDirectory= при остановке не удаляются — то есть StateDirectory= подходит именно для постоянных данных. Для пользовательских юнитов LogsDirectory= уходит в $XDG_STATE_HOME/log.

[Service]
User=exchange
StateDirectory=exchange
WorkingDirectory=/var/lib/exchange
ProtectHome=yes
ProtectSystem=strict
PrivateTmp=yes
NoNewPrivileges=yes

После такого переезда ProtectHome=yes перестаёт быть проблемой — служба просто ничего не хочет от /home, и весь вопрос статьи снимается. Отдельно скажу, когда я всё-таки оставляю обмен через домашний каталог: когда путь зашит в закрытый бинарник вендора, когда домашки монтируются по NFS и туда пишут ещё и живые пользователи, и когда каталог — часть согласованного с контрагентом протокола обмена и переименовать его нельзя. Во всех трёх случаях связка tmpfs + Bind* — нормальное, а не костыльное решение.

Перенос данных из /home в /var/lib занимает полчаса и закрывает тему навсегда. Если у вас есть эти полчаса — делайте перенос, а не Bind-заплатку.

Побочные эффекты, о которых узнают позже

Первое и самое неочевидное — /run/user. ProtectHome прячет не только /home и /root, но и /run/user, а там живёт содержимое $XDG_RUNTIME_DIR: сокет сессионной шины D-Bus, сокеты gpg-agent, ssh-agent, keyring. Симптомы выглядят как поломка совсем другого софта: «gpg: не могу открыть /run/user/1001/gnupg/S.gpg-agent», «Failed to connect to bus», подпись перестала работать после включения hardening. Ищут в gpg, а надо смотреть в ProtectHome. Если службе действительно нужен агент — либо возвращайте нужный подкаталог через BindPaths при tmpfs, либо переводите gpg на явный GNUPGHOME в /var/lib.

Второе — bind-монтирования отключают распространение точек монтирования из юнита наружу. Документация формулирует так: эти настройки разрывают propagation монтирований от процессов юнита к хосту, и потому их нельзя использовать для служб, которые должны устанавливать точки монтирования в основном mount namespace. Если ваш сервис монтирует CIFS/NFS/loop-образы для остальной системы, Bind* его сломает, и это не баг.

Третье — вложенные точки монтирования. Здесь чаще ошибаются в обратную сторону: bind в BindPaths= по умолчанию рекурсивный, так что уже смонтированные внутри источника LV или NFS-шара внутри юнита будут видны. Проблемы начинаются с тем, что смонтировано позже — autofs, ручной mount после старта службы. В systemd.exec(5) прямым текстом сказано, что ограничение доступа не распространяется на подмонтирования, созданные позднее, и что такая блокировка не полная; а для ReadWritePaths=/ReadOnlyPaths= отмечено, что монтирования с хоста продолжают появляться в namespace юнита. Если нужно, наоборот, не тянуть вложенное, пишите BindPaths=/home/exchange/out:/home/exchange/out:norbind.

И честно про уровень защиты. ProtectHome — не тюрьма. Документация не скрывает, что настройка «не может обеспечить защиту во всех случаях» и что эффект этих настроек могут отменить привилегированные процессы — поэтому man рекомендует дополнять их CapabilityBoundingSet=~CAP_SYS_ADMIN или SystemCallFilter=~@mount. Это страховка от чтения чужих персональных данных скомпрометированным демоном и от случайной записи не туда — очень дёшево и очень полезно, но не замена изоляции контейнером или отдельной ВМ. Поэтому мой приоритет такой: сначала вынести данные службы из /home, потом включить ProtectHome=yes, и только если первое невозможно — tmpfs с точечными Bind-путями. Отключать ProtectHome насовсем ради одного каталога не нужно никогда.

Порядок приоритетов: 1) вынести данные в /var/lib через StateDirectory=; 2) ProtectHome=yes; 3) если нельзя — ProtectHome=tmpfs плюс точечные BindPaths=. Полное отключение ProtectHome в этот список не входит.
Памятка: Побочные эффекты, о которых узнают позже — схема
Памятка: Побочные эффекты, о которых узнают позже. Открыть схему в полном размере

Частые вопросы

Почему chmod 777 и запуск от root не возвращают службе доступ к /home?

Потому что ограничение работает не на уровне прав UNIX, а на уровне mount namespace. При ProtectHome=yes поверх /home, /root и /run/user в пространстве имён юнита монтируются пустые недоступные узлы, и внутри службы каталога просто нет — ни для пользователя, ни для root. Права на объекте, которого процесс не видит, роли не играют.

Можно ли оставить ProtectHome=yes и прокинуть один каталог через BindPaths?

Нет. В systemd.exec(5) прямо сказано, что BindPaths= и BindReadOnlyPaths= нельзя использовать для точек монтирования под /home и другими защищёнными каталогами, если задан ProtectHome=yes, и рекомендуется вместо этого применять ProtectHome=tmpfs или TemporaryFileSystem= с :ro. На практике такой юнит, как правило, не стартует с ошибкой настройки пространства имён (status=226/NAMESPACE).

Чем отличается ProtectHome=tmpfs от read-only?

read-only оставляет реальное содержимое /home, /root и /run/user видимым, но запрещает запись. tmpfs монтирует поверх них пустые временные файловые системы в режиме только для чтения — содержимое чужих домашних каталогов не видно вообще, и при этом нужные пути можно вернуть точечно через BindPaths= или BindReadOnlyPaths=. Для строгой изоляции берите tmpfs, для быстрого решения «всё видно, писать нельзя» — read-only с ReadWritePaths= на нужный подкаталог.

В моём юните нет строки ProtectHome, откуда ограничение?

Смотрите эффективную конфигурацию: `systemctl cat` покажет vendor-юнит пакета и все drop-in из /usr/lib/systemd/system/*.d и /etc/systemd/system/*.d, `systemctl show -p ProtectHome` — итоговое значение. Кроме того, ProtectHome=read-only включается неявно при DynamicUser=yes вместе с ProtectSystem=strict, PrivateTmp=, NoNewPrivileges=, RestrictSUIDSGID= и RemoveIPC=.

После включения hardening сломался gpg и подпись файлов. Это тоже ProtectHome?

Очень вероятно. ProtectHome прячет ещё и /run/user, где лежат сокеты gpg-agent, ssh-agent, keyring и сессионной шины D-Bus. Либо верните нужный подкаталог через BindPaths при ProtectHome=tmpfs, либо переведите службу на собственный GNUPGHOME в /var/lib, что надёжнее.

Работает ли ProtectHome в пользовательских юнитах systemctl --user?

Зависит от версии systemd. В man systemd 252 (Debian 12) сказано, что ключ доступен системным службам, а в пользовательских инстансах менеджера — только при включённом PrivateUsers=, для чего ядро должно разрешать непривилегированные user namespace. Если они запрещены sysctl, настройка namespace не удастся и юнит не запустится. Точную формулировку для своей версии смотрите в `man systemd.exec` на самом сервере.

Служба пишет «Read-only file system», хотя ProtectHome выключен. Что это?

Это почти наверняка ProtectSystem=strict (явный или неявный через DynamicUser=yes) или ReadOnlyPaths=. При strict вся иерархия, кроме /dev, /proc и /sys, доступна только на чтение, и каждый каталог для записи нужно перечислить в ReadWritePaths= либо создать через StateDirectory=, CacheDirectory= или LogsDirectory=. Проверить итоговые значения можно командой `systemctl show UNIT -p ProtectSystem -p ReadWritePaths`.

Столкнулись с похожей задачей? Обращайтесь — решим

Если у вас происходит что-то из описанного в этой статье — или любая другая проблема с ИТ-инфраструктурой, — обращайтесь в любое время. Мои специалисты и я лично разберём ситуацию, найдём настоящую причину и доведём до решения.

Возьмёмся и за разовую задачу, и за постоянное обслуживание. Первичная консультация — бесплатно и без обязательств.

📞 +7 903 729-62-41 💬 MAX: +7 903 729-62-41 ✈ Telegram @ITfresh_Boss

С уважением, Семёнов Евгений Сергеевич, директор «АйТи Фреш» — IT-аутсорсинг для компаний до 50 рабочих мест, 15+ лет практики

Источники

© ООО «АйТи-Фреш» · Москва · Все статьи