263 lines
8.2 KiB
Go
263 lines
8.2 KiB
Go
package instance
|
|
|
|
// This file is the application boundary between persisted instances and WASM
|
|
// integrations. It deliberately owns all manifest/artifact/secrets handling;
|
|
// HTTP handlers only use the typed methods below.
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/catalog"
|
|
"git.zaynet.fr/DoGaMa/DoGaMa-serv/internal/module"
|
|
"gopkg.in/yaml.v3"
|
|
)
|
|
|
|
var ErrModuleUnavailable = errors.New("module unavailable")
|
|
|
|
type ModuleService struct {
|
|
secrets SecretRepository
|
|
catalog catalog.Repository
|
|
}
|
|
|
|
func NewModuleService(secrets SecretRepository, repository catalog.Repository) *ModuleService {
|
|
return &ModuleService{secrets: secrets, catalog: repository}
|
|
}
|
|
|
|
type ServerInfo struct {
|
|
Name string `json:"name"`
|
|
GameVersion string `json:"game_version"`
|
|
Description string `json:"description"`
|
|
WorldID string `json:"world_id"`
|
|
}
|
|
type Metrics struct {
|
|
CurrentPlayers int `json:"current_players"`
|
|
MaxPlayers int `json:"max_players"`
|
|
}
|
|
type Player struct {
|
|
PlayerID string `json:"player_id"`
|
|
UserID string `json:"user_id"`
|
|
DisplayName string `json:"display_name"`
|
|
PingMS float64 `json:"ping_ms"`
|
|
}
|
|
|
|
// Ban is a normalized, stable selection value returned by an adapter. The
|
|
// identifier, rather than the display name, is sent back for an unban action.
|
|
type Ban struct {
|
|
PlayerID string `json:"player_id"`
|
|
DisplayName string `json:"display_name"`
|
|
}
|
|
type Live struct {
|
|
Capabilities map[string]bool
|
|
ServerInfo *ServerInfo
|
|
Metrics *Metrics
|
|
Players []Player
|
|
Bans []Ban
|
|
BansChecked bool
|
|
Unavailable bool
|
|
}
|
|
|
|
type moduleManifest struct {
|
|
ID string `yaml:"id"`
|
|
Runtime struct {
|
|
Type string `yaml:"type"`
|
|
ABI string `yaml:"abi"`
|
|
} `yaml:"runtime"`
|
|
Compatibility struct {
|
|
ManagerAPI string `yaml:"manager_api"`
|
|
ModuleAPI string `yaml:"module_api"`
|
|
} `yaml:"compatibility"`
|
|
Capabilities []string `yaml:"capabilities"`
|
|
Permissions struct {
|
|
Network struct {
|
|
PortIDs []string `yaml:"port_ids"`
|
|
HTTPMethods []string `yaml:"http_methods"`
|
|
Protocols []string `yaml:"protocols"`
|
|
} `yaml:"network"`
|
|
} `yaml:"permissions"`
|
|
Limits struct {
|
|
MemoryMB uint32 `yaml:"memory_mb"`
|
|
TimeoutMS int `yaml:"timeout_ms"`
|
|
MaxResponseBytes int `yaml:"max_response_bytes"`
|
|
MaxConcurrentCalls int `yaml:"max_concurrent_calls"`
|
|
} `yaml:"limits"`
|
|
Configuration []struct {
|
|
ID string `yaml:"id"`
|
|
Type string `yaml:"type"`
|
|
} `yaml:"configuration"`
|
|
Artifacts struct {
|
|
WASM string `yaml:"wasm"`
|
|
SHA256 string `yaml:"sha256"`
|
|
} `yaml:"artifacts"`
|
|
}
|
|
|
|
func (s *ModuleService) runtime(ctx context.Context, value StoredInstance) (*module.Runtime, moduleManifest, error) {
|
|
if s == nil || s.secrets == nil || s.catalog == nil {
|
|
return nil, moduleManifest{}, ErrModuleUnavailable
|
|
}
|
|
snapshot, err := s.catalog.Get(ctx, value.Preview.Template.ID, value.Preview.Template.Version)
|
|
if err != nil || snapshot.Template.Integration == nil {
|
|
return nil, moduleManifest{}, ErrModuleUnavailable
|
|
}
|
|
integration := snapshot.Template.Integration
|
|
if integration.ModuleID == "" || snapshot.Template.Module == nil || !validTemplateModulePath(snapshot.Template.Module.Path) {
|
|
return nil, moduleManifest{}, ErrModuleUnavailable
|
|
}
|
|
body, ok := snapshot.ModuleFiles[snapshot.Template.Module.Path]
|
|
if !ok {
|
|
return nil, moduleManifest{}, ErrModuleUnavailable
|
|
}
|
|
var manifest moduleManifest
|
|
if yaml.Unmarshal(body, &manifest) != nil || manifest.ID != integration.ModuleID || manifest.Runtime.Type != "wasm" || manifest.Runtime.ABI != module.ABI || manifest.Compatibility.ManagerAPI == "" || manifest.Compatibility.ModuleAPI == "" {
|
|
return nil, moduleManifest{}, ErrModuleUnavailable
|
|
}
|
|
var port int
|
|
for _, p := range value.Preview.Ports {
|
|
if p.ID == integration.PortID && p.Purpose == "integration" {
|
|
port = p.ContainerPort
|
|
break
|
|
}
|
|
}
|
|
if port == 0 || !contains(manifest.Permissions.Network.PortIDs, integration.PortID) {
|
|
return nil, moduleManifest{}, ErrModuleUnavailable
|
|
}
|
|
methods := map[string]bool{}
|
|
tcpAllowed := false
|
|
for _, protocol := range manifest.Permissions.Network.Protocols {
|
|
switch protocol {
|
|
case "http":
|
|
case "tcp":
|
|
tcpAllowed = true
|
|
default:
|
|
return nil, moduleManifest{}, ErrModuleUnavailable
|
|
}
|
|
}
|
|
for _, method := range manifest.Permissions.Network.HTTPMethods {
|
|
if method == "GET" || method == "POST" {
|
|
methods[method] = true
|
|
} else {
|
|
return nil, moduleManifest{}, ErrModuleUnavailable
|
|
}
|
|
}
|
|
if (len(methods) == 0 && !tcpAllowed) || manifest.Artifacts.WASM == "" || strings.Contains(manifest.Artifacts.WASM, "/") || strings.Contains(manifest.Artifacts.WASM, "..") {
|
|
return nil, moduleManifest{}, ErrModuleUnavailable
|
|
}
|
|
config, secrets := map[string]string{}, map[string]string{}
|
|
stored, err := s.secrets.LoadInstanceSecrets(ctx, value.ID)
|
|
if err != nil {
|
|
return nil, moduleManifest{}, ErrModuleUnavailable
|
|
}
|
|
for _, field := range manifest.Configuration {
|
|
if field.ID == "" {
|
|
return nil, moduleManifest{}, ErrModuleUnavailable
|
|
}
|
|
if field.Type == "secret" {
|
|
if v := stored[field.ID]; v != "" {
|
|
secrets[field.ID] = v
|
|
}
|
|
} else if v := value.Preview.Configuration[field.ID]; v != "" {
|
|
config[field.ID] = v
|
|
}
|
|
}
|
|
wasm, ok := snapshot.ModuleFiles["module/"+manifest.Artifacts.WASM]
|
|
if !ok {
|
|
return nil, moduleManifest{}, ErrModuleUnavailable
|
|
}
|
|
r, err := module.New(ctx, wasm, manifest.Artifacts.SHA256, manifest.Capabilities, module.Limits{MemoryMB: manifest.Limits.MemoryMB, Timeout: durationMS(manifest.Limits.TimeoutMS), MaxResponseBytes: manifest.Limits.MaxResponseBytes, MaxConcurrentCall: manifest.Limits.MaxConcurrentCalls}, module.Binding{InstanceID: value.ID, ContainerPort: port, AllowedMethods: methods, Configuration: config, Secrets: secrets})
|
|
if err != nil {
|
|
return nil, moduleManifest{}, fmt.Errorf("%w: invalid integration", ErrModuleUnavailable)
|
|
}
|
|
return r, manifest, nil
|
|
}
|
|
|
|
func durationMS(v int) time.Duration { return time.Duration(v) * time.Millisecond }
|
|
|
|
func validTemplateModulePath(value string) bool {
|
|
return strings.HasPrefix(value, "module/") && !strings.Contains(value, "..") && !strings.HasPrefix(value, "/")
|
|
}
|
|
|
|
func contains(values []string, needle string) bool {
|
|
for _, v := range values {
|
|
if v == needle {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (s *ModuleService) Live(ctx context.Context, value StoredInstance, players bool) Live {
|
|
r, manifest, err := s.runtime(ctx, value)
|
|
if err != nil {
|
|
return Live{Unavailable: true}
|
|
}
|
|
live := Live{Capabilities: map[string]bool{}}
|
|
for _, c := range manifest.Capabilities {
|
|
live.Capabilities[c] = true
|
|
}
|
|
if live.Capabilities["server_info"] {
|
|
var out ServerInfo
|
|
if r.Call(ctx, "get_server_info", struct{}{}, &out) == nil {
|
|
live.ServerInfo = &out
|
|
}
|
|
}
|
|
if live.Capabilities["metrics"] {
|
|
var out Metrics
|
|
if r.Call(ctx, "get_metrics", struct{}{}, &out) == nil {
|
|
live.Metrics = &out
|
|
}
|
|
}
|
|
if players && live.Capabilities["player_list"] {
|
|
var out struct {
|
|
Players []Player `json:"players"`
|
|
}
|
|
if r.Call(ctx, "list_players", struct{}{}, &out) == nil {
|
|
live.Players = out.Players
|
|
}
|
|
}
|
|
if live.Capabilities["list_bans"] {
|
|
live.BansChecked = true
|
|
var out struct {
|
|
Bans []Ban `json:"bans"`
|
|
}
|
|
if r.Call(ctx, "list_bans", struct{}{}, &out) == nil {
|
|
live.Bans = out.Bans
|
|
} else {
|
|
live.BansChecked = false
|
|
}
|
|
}
|
|
return live
|
|
}
|
|
|
|
// ListBans is deliberately separate from Live so POST validation always uses
|
|
// a fresh runtime result rather than browser-rendered values.
|
|
func (s *ModuleService) ListBans(ctx context.Context, value StoredInstance) ([]Ban, error) {
|
|
r, manifest, err := s.runtime(ctx, value)
|
|
if err != nil || !contains(manifest.Capabilities, "list_bans") {
|
|
return nil, ErrModuleUnavailable
|
|
}
|
|
var out struct {
|
|
Bans []Ban `json:"bans"`
|
|
}
|
|
if err := r.Call(ctx, "list_bans", struct{}{}, &out); err != nil {
|
|
return nil, ErrModuleUnavailable
|
|
}
|
|
return out.Bans, nil
|
|
}
|
|
|
|
func (s *ModuleService) Action(ctx context.Context, value StoredInstance, capability, operation string, request any) error {
|
|
r, manifest, err := s.runtime(ctx, value)
|
|
if err != nil || !contains(manifest.Capabilities, capability) {
|
|
return ErrModuleUnavailable
|
|
}
|
|
var out struct {
|
|
Accepted bool `json:"accepted"`
|
|
}
|
|
if err := r.CallData(ctx, operation, request, &out); err != nil || !out.Accepted {
|
|
return ErrModuleUnavailable
|
|
}
|
|
return nil
|
|
}
|