Перейти к содержимому

Модули

Модуль — это отдельный исполняемый файл, который сервер запускает изолированным субпроцессом и общается с ним по 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 + AuthProviderauth.provider
СкиныSkinProviders + SkinProvideryggdrasil.skinProvider
Доступ к сборкамAccessSources + AccessSourceaccess.sources.<имя>.type
НовостиNewsSources + NewsSourcenews.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.service
sudo 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 — но модуль, который так делает, стоит выбирать особенно придирчиво.