Files
bd2/go/internal/client/config/config.go
T

200 lines
6.1 KiB
Go

// Package config owns the client-side connection settings consumed by the
// standalone setup tool and the Local Identity plugin.
package config
import (
"encoding/json"
"errors"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
clientlayout "bd2server/internal/client/layout"
)
const (
SchemaVersion = 2
FileName = "bd2.client.json"
CDNOfficial CDNMode = "official"
CDNLocal CDNMode = "local"
CDNServer CDNMode = "server"
)
type CDNMode string
type Settings struct {
ProxyURL string `json:"proxy_url,omitempty"`
SchemaVersion int `json:"schema_version"`
ServerOrigin string `json:"server_origin"`
CDNMode CDNMode `json:"cdn_mode"`
LocalResourceDirectory string `json:"local_resource_directory,omitempty"`
}
func Path(gameDir string) string {
if installation, err := clientlayout.Resolve(gameDir); err == nil {
return filepath.Join(installation.Config, FileName)
}
return filepath.Join(filepath.Clean(gameDir), "BepInEx", "config", FileName)
}
func Normalize(in Settings) (Settings, error) {
origin, err := NormalizeOrigin(in.ServerOrigin)
if err != nil {
return Settings{}, err
}
proxyURL, err := NormalizeProxyURL(in.ProxyURL)
if err != nil {
return Settings{}, err
}
localDirectory := strings.TrimSpace(in.LocalResourceDirectory)
switch in.CDNMode {
case CDNOfficial, CDNServer:
if localDirectory != "" {
return Settings{}, errors.New("client config: local_resource_directory is only valid in local mode")
}
case CDNLocal:
if localDirectory == "" {
return Settings{}, errors.New("client config: local mode requires local_resource_directory")
}
localDirectory, err = filepath.Abs(filepath.Clean(localDirectory))
if err != nil {
return Settings{}, fmt.Errorf("client config: resolve local resource directory: %w", err)
}
default:
return Settings{}, fmt.Errorf("client config: unsupported CDN mode %q", in.CDNMode)
}
return Settings{
SchemaVersion: SchemaVersion,
ProxyURL: proxyURL,
ServerOrigin: origin,
CDNMode: in.CDNMode,
LocalResourceDirectory: localDirectory,
}, nil
}
func NormalizeOrigin(raw string) (string, error) {
raw = strings.TrimSpace(raw)
parsed, err := url.Parse(raw)
if err != nil {
return "", fmt.Errorf("client config: parse server origin: %w", err)
}
if parsed.Scheme != "http" && parsed.Scheme != "https" {
return "", errors.New("client config: server address must use http or https")
}
if parsed.Host == "" {
return "", errors.New("client config: server address must include a host")
}
if parsed.User != nil {
return "", errors.New("client config: credentials are not allowed in the server address")
}
if parsed.RawQuery != "" || parsed.Fragment != "" {
return "", errors.New("client config: server address cannot contain a query or fragment")
}
if parsed.Path != "" && parsed.Path != "/" {
return "", errors.New("client config: enter only the server origin, without /game or another path")
}
if parsed.Scheme == "http" && !isLoopback(parsed.Hostname()) {
return "", errors.New("client config: non-loopback servers must use https")
}
parsed.Path = ""
parsed.RawPath = ""
return strings.TrimSuffix(parsed.String(), "/"), nil
}
// NormalizeProxyURL accepts only an explicit HTTP proxy endpoint. Errors never echo input.
func NormalizeProxyURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", nil
}
invalid := errors.New("client config: proxy must be an HTTP URL with a host and numeric port (1-65535), without credentials, path, query or fragment")
parsed, err := url.Parse(raw)
if err != nil || parsed.Scheme != "http" || parsed.Host == "" || parsed.User != nil || parsed.RawQuery != "" || parsed.ForceQuery || parsed.Fragment != "" || strings.Contains(raw, "#") || (parsed.Path != "" && parsed.Path != "/") || parsed.RawPath != "" {
return "", invalid
}
host, port, err := net.SplitHostPort(parsed.Host)
if err != nil || host == "" || port == "" || strings.ContainsAny(host, " \t\r\n%") {
return "", invalid
}
for _, ch := range port {
if ch < '0' || ch > '9' {
return "", invalid
}
}
number, err := strconv.Atoi(port)
if err != nil || number < 1 || number > 65535 {
return "", invalid
}
parsed.Host = net.JoinHostPort(host, strconv.Itoa(number))
parsed.Path = ""
return parsed.String(), nil
}
func isLoopback(host string) bool {
if strings.EqualFold(host, "localhost") {
return true
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
func Save(gameDir string, in Settings) (Settings, error) {
settings, err := Normalize(in)
if err != nil {
return Settings{}, err
}
path := Path(gameDir)
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return Settings{}, fmt.Errorf("client config: create config directory: %w", err)
}
data, err := json.MarshalIndent(settings, "", " ")
if err != nil {
return Settings{}, err
}
data = append(data, '\n')
temporary, err := os.CreateTemp(filepath.Dir(path), ".bd2-client-*.tmp")
if err != nil {
return Settings{}, fmt.Errorf("client config: create temporary config: %w", err)
}
temporaryPath := temporary.Name()
defer func() { _ = os.Remove(temporaryPath) }()
if err = temporary.Chmod(0o600); err == nil {
_, err = temporary.Write(data)
}
if err == nil {
err = temporary.Sync()
}
if closeErr := temporary.Close(); err == nil {
err = closeErr
}
if err != nil {
return Settings{}, fmt.Errorf("client config: stage config: %w", err)
}
if err := replaceFile(temporaryPath, path); err != nil {
return Settings{}, fmt.Errorf("client config: install config: %w", err)
}
return settings, nil
}
func Load(gameDir string) (Settings, error) {
data, err := os.ReadFile(Path(gameDir))
if err != nil {
return Settings{}, err
}
var settings Settings
decoder := json.NewDecoder(strings.NewReader(string(data)))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&settings); err != nil {
return Settings{}, fmt.Errorf("client config: decode: %w", err)
}
if settings.SchemaVersion != SchemaVersion {
return Settings{}, fmt.Errorf("client config: unsupported schema_version %d", settings.SchemaVersion)
}
return Normalize(settings)
}