Модули
Модуль — это отдельный исполняемый файл, который сервер запускает изолированным
субпроцессом и общается с ним по gRPC. Модуль добавляет команды в единый реестр
сервера, поэтому его команды появляются в CLI, консоли, exec и help наравне
со встроенными; он же может подписаться на события и отдать серверу свои
провайдеры — аутентификации, скинов, доступа к сборкам и новостей. Падение модуля
не роняет сервер, а писать модуль можно на любом языке, поддерживающем
gRPC-контракт (Go — из коробки, через SDK).
Модуль на Go
SDK живёт отдельным Go-модулем github.com/laminara/laminara/sdk/go, поэтому
подключается одной строкой и не тянет за собой код сервера:
go get github.com/laminara/laminara/sdk/goРеализуйте два метода — Info() и Commands():
package main
import ( "context" "fmt" "io" "strings"
module "github.com/laminara/laminara/sdk/go/module")
type greeter struct { greeting string}
func (g *greeter) Info() module.Info { return module.Info{Name: "greeter", Version: "0.1.0", Description: "Пример модуля"}}
func (g *greeter) Commands() []module.Command { return []module.Command{{ Name: "greet", Aliases: []string{"hi"}, Synopsis: "поприветствовать", Run: func(_ context.Context, args []string, out io.Writer) error { who := "мир" if len(args) > 0 { who = strings.Join(args, " ") } fmt.Fprintf(out, "%s, %s!\n", g.greeting, who) return nil }, }}}
func main() { module.Serve(&greeter{greeting: "Привет"})}Run пишет вывод в out — он стримится обратно тому, кто вызвал команду.
Конфигурация
Чтобы модуль принимал настройки, реализуйте необязательный интерфейс
Configure(config []byte) error — сервер передаст блок конфигурации
(сырой JSON) до чтения Info. Понадобится импорт encoding/json:
func (g *greeter) Configure(config []byte) error { var settings struct { Greeting string `json:"greeting"` } if err := json.Unmarshal(config, &settings); err != nil { return err } if settings.Greeting != "" { g.greeting = settings.Greeting } return nil}События
Модуль может реагировать на события сервера — реализуйте Events() (список топиков)
и OnEvent(ctx, topic, data):
func (g *greeter) Events() []string { return []string{"build.published"}}
func (g *greeter) OnEvent(_ context.Context, topic string, data map[string]string) error { fmt.Printf("%s: сборка %s\n", topic, data["name"]) return nil}Доступные топики: build.prepared, build.published, build.deleted; имя сборки —
в data["name"]. Каждое событие доставляется подписанному модулю в отдельной
горутине, поэтому медленный модуль не блокирует сервер.
Провайдеры
Модуль может отдать серверу провайдер — реализацию одного из адаптеров, которые
сервер выбирает по имени в конфиге. Провайдер модуля встаёт в тот же реестр, что и
встроенные, поэтому в конфиге он пишется ровно так же, как jsonfile или http:
| Вид | Интерфейс модуля | Где называется в конфиге |
|---|---|---|
| Аутентификация | AuthProviders + AuthProvider | auth.provider |
| Скины | SkinProviders + SkinProvider | yggdrasil.skinProvider |
| Доступ к сборкам | AccessSources + AccessSource | access.sources.<имя>.type |
| Новости | NewsSources + NewsSource | news.source.type |
Первый интерфейс перечисляет имена, второй — работает. Сервер запрашивает список
при загрузке модуля, а открывает провайдер тогда, когда доходит до него в конфиге,
и передаёт соседний блок настроек: для аутентификации это auth.config, для скинов
yggdrasil.skinConfig, для доступа и новостей — config рядом с type.
func (m *cms) AuthProviders() []string { return []string{"mycms"} }
func (m *cms) OpenAuth(_ context.Context, name string, config []byte) (module.AuthProvider, error) { var settings struct { URL string `json:"url"` } if err := json.Unmarshal(config, &settings); err != nil { return nil, err } return &cmsAuth{url: settings.URL}, nil}
func (a *cmsAuth) Authenticate(ctx context.Context, creds module.Credentials) (module.Identity, error) { switch verdict := a.ask(ctx, creds); verdict { case verdictWrongPassword: return module.Identity{}, module.ErrInvalidCredentials case verdictNeedsCode: return module.Identity{}, module.ErrTwoFactorRequired } return module.Identity{Subject: "42", Username: creds.Username, UUID: "..."}, nil}Две ошибки из SDK — ErrInvalidCredentials и ErrTwoFactorRequired — сервер понимает
как отдельные исходы: первая приводит к отказу и тратит бюджет попыток, вторая просит
лаунчер показать поле для кода из приложения-аутентификатора. Любая другая ошибка
считается сбоем модуля, а не неверным паролем. UUID — строка в обычном формате; если
модуль оставит её пустой, сервер посчитает офлайновый UUID от ника.
Имя провайдера должно быть свободным: занятое встроенным адаптером или другим модулем имя сервер не примет и напишет об этом в журнал, а не подменит существующий провайдер молча. Модули загружаются раньше, чем сервер читает конфиг, поэтому провайдер модуля доступен уже при первом запуске — и виден в списке вариантов на экране настроек.
Сборка и установка
Соберите модуль в обычный бинарь и положите его в каталог модулей сервера:
go build -o /var/lib/laminara/modules/greeter ./greeterУкажите каталог и (необязательно) конфигурацию модулей в конфиге сервера — ключ конфигурации совпадает с именем файла модуля:
{ "modules": { "dir": "/var/lib/laminara/modules", "config": { "greeter": { "greeting": "Здорово" } } }}Блок modules.config — настройки самого модуля. Провайдер настраивается там, где
сервер его выбирает, поэтому модуль с провайдером аутентификации выглядит так:
{ "modules": { "dir": "/var/lib/laminara/modules" }, "auth": { "provider": "mycms", "config": { "url": "https://site.example/api" } }}Сервер подхватит модуль при запуске. Проверьте:
$ laminara-server exec "modules"greeter 0.1.0 Пример модуля
$ laminara-server exec "greet Игорь"Здорово, Игорь!
$ laminara-server status --json # modulesLoaded увеличитсяКоманда модуля видна и в help, и в интерактивной консоли (наберите /, затем
имя команды). Устройство обмена — go-plugin: сервер запускает бинарь и говорит с ним
по gRPC. Контракт лежит в proto/laminara/module/v1, а сгенерированный по нему код
входит в SDK, поэтому модулю не нужны ни buf, ни кодогенерация.
Модуль не поднялся
Сервер пишет причину в журнал и повторяет её в laminara-server doctor --only modules.
«модулю негде создать свой сокет: временный каталог … только для чтения». Модуль — отдельная
программа, и говорит она с сервером через unix-сокет во временном каталоге. Если unit systemd
написан с ProtectSystem=strict, но без PrivateTmp=true, /tmp доступен только для чтения. Сам
сервер в таком случае заводит tmp/ рядом со своими данными и модули поднимаются как обычно —
эта ошибка означает, что запасной каталог тоже не получился. Перепишите unit тем, что печатает
сам сервер:
laminara-server systemd-config --config /etc/laminara/config.json \ | sudo tee /etc/systemd/system/laminara-server.servicesudo systemctl daemon-reload && sudo systemctl restart laminara-server«модуль пропущен: файл не исполняемый». chmod +x на файл модуля.
«модуль не поздоровался с сервером». Файл не модуль Laminara, собран под другую версию SDK или
падает при старте. Запустите его руками — модуль ответит This binary is a plugin. These are not meant to be executed directly.; всё остальное на выходе и есть причина.
Владелец файла модуля роли не играет: сервер запускает его от своего пользователя, и достаточно
права на чтение и запуск. Строки Owner: и Group: в журнале — справка go-plugin, а не диагноз.
Чем модуль может распоряжаться
Каталог модулей — то же доверие, что и сам бинарь сервера. Сервер запускает оттуда любой исполняемый файл от своего пользователя: модуль читает те же файлы, ходит в ту же базу и в ту же сеть. Изоляции нет — отдельный процесс нужен, чтобы падение модуля не роняло сервер, а не чтобы ограничить его в правах.
Отсюда два правила:
- каталог принадлежит пользователю сервера и никому больше не пишется
(
chown laminara: /var/lib/laminara/modules && chmod 750 …). Кто может положить туда файл — тот выполняет код на вашем сервере; - чужой модуль ставится так же, как любая программа на сервер: по исходникам, которые вы можете прочитать, из релиза, за который отвечает известный вам автор.
Провайдер аутентификации из модуля вдобавок решает, кого пускать: он видит логин и пароль игрока в открытом виде и сам выносит вердикт. Это неизбежно для внешней CMS — но модуль, который так делает, стоит выбирать особенно придирчиво.