fixes and improvements for new server settings menu

This commit is contained in:
onysd 2026-08-26 00:37:25 +03:00
parent 902f3606c2
commit 66f9c0bc1e
27 changed files with 2244 additions and 40 deletions

View file

@ -299,6 +299,11 @@ type Config struct {
// StickerSeedDir, this expects no export manifest, just raw files dropped
// in. Missing directory is skipped, not an error.
GifSeedDir string
// IdentityDir holds the admin-editable server identity (name/description
// in identity.json, icon as icon.<ext>) served over /owpengram/server-info
// and /owpengram/server-icon -- see internal/identity. Read fresh on every
// request, so admin edits apply immediately with no server restart.
IdentityDir string
// BusinessAIProvider 控制服务端 Business automation 回复生成器。
// 空值/"echo" 回显触发私聊文本,用于跑通后续 AI provider 链路;
// "template" 使用 quick reply 模板。
@ -776,6 +781,7 @@ func Load() (Config, error) {
StickerSeedMaxSets: envIntOr("TELESRV_STICKER_SEED_MAX_SETS", 300),
PremiumPromoSeedDir: envOr("TELESRV_PREMIUM_PROMO_SEED_DIR", "data/premium-promo"),
GifSeedDir: envOr("TELESRV_GIF_SEED_DIR", "data/gifs"),
IdentityDir: envOr("TELESRV_IDENTITY_DIR", "data/identity"),
MapboxToken: envOr("TELESRV_MAPBOX_TOKEN", ""),
MapTileCacheDir: envOr("TELESRV_MAPTILE_CACHE_DIR", "data/maptiles"),
ExternalMediaEnable: envBoolOr("TELESRV_EXTERNAL_MEDIA_ENABLE", true),

View file

@ -0,0 +1,152 @@
// Package identity stores the admin-editable server name/description/icon
// shown to clients over the same-port HTTP endpoints in internal/mtprotoedge
// (/owpengram/server-info, /owpengram/server-icon). It is deliberately not
// part of internal/config's Config: config is loaded once at process start
// from .env, while identity is meant to be edited from the admin web panel
// and take effect immediately, with no server restart -- so it lives as
// plain files on disk, read fresh on every request instead of cached in
// memory.
package identity
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
const (
metaFileName = "identity.json"
iconBaseName = "icon"
)
// Info is the editable identity shown to clients.
type Info struct {
Name string `json:"name"`
Description string `json:"description"`
// IconExt is the icon file's extension (e.g. ".png"), empty when no
// icon has been uploaded. Kept alongside Name/Description so Store can
// find the icon file without a directory listing.
IconExt string `json:"icon_ext,omitempty"`
}
// Store reads/writes Info and the icon file under a directory (typically
// Config.IdentityDir). All methods are safe to call from multiple goroutines
// and multiple processes (the admin binary writes, the main server binary
// reads) -- writes are atomic via a temp file + rename.
type Store struct {
dir string
}
func NewStore(dir string) *Store {
return &Store{dir: dir}
}
func (s *Store) metaPath() string {
return filepath.Join(s.dir, metaFileName)
}
func (s *Store) iconPath(ext string) string {
return filepath.Join(s.dir, iconBaseName+ext)
}
// Get reads the current identity. A missing file is not an error -- it just
// means nothing has been configured yet, so Info{} (all empty) is returned.
func (s *Store) Get() (Info, error) {
data, err := os.ReadFile(s.metaPath())
if os.IsNotExist(err) {
return Info{}, nil
}
if err != nil {
return Info{}, fmt.Errorf("identity: read: %w", err)
}
var info Info
if err := json.Unmarshal(data, &info); err != nil {
return Info{}, fmt.Errorf("identity: decode: %w", err)
}
return info, nil
}
// SetText updates name/description, preserving whatever icon is already
// configured.
func (s *Store) SetText(name, description string) error {
info, err := s.Get()
if err != nil {
return err
}
info.Name = strings.TrimSpace(name)
info.Description = strings.TrimSpace(description)
return s.save(info)
}
// SetIcon replaces the icon file (removing any previous one under a
// different extension) and records its extension in identity.json.
// ext must include the leading dot (e.g. ".png").
func (s *Store) SetIcon(data []byte, ext string) error {
info, err := s.Get()
if err != nil {
return err
}
if err := os.MkdirAll(s.dir, 0o755); err != nil {
return fmt.Errorf("identity: mkdir: %w", err)
}
if info.IconExt != "" && info.IconExt != ext {
_ = os.Remove(s.iconPath(info.IconExt))
}
if err := writeFileAtomic(s.iconPath(ext), data, 0o644); err != nil {
return fmt.Errorf("identity: write icon: %w", err)
}
info.IconExt = ext
return s.save(info)
}
// RemoveIcon deletes the configured icon, if any.
func (s *Store) RemoveIcon() error {
info, err := s.Get()
if err != nil {
return err
}
if info.IconExt == "" {
return nil
}
_ = os.Remove(s.iconPath(info.IconExt))
info.IconExt = ""
return s.save(info)
}
// Icon returns the icon's raw bytes and its file extension, or ("", nil,
// false) when no icon is configured.
func (s *Store) Icon() (data []byte, ext string, ok bool) {
info, err := s.Get()
if err != nil || info.IconExt == "" {
return nil, "", false
}
raw, err := os.ReadFile(s.iconPath(info.IconExt))
if err != nil {
return nil, "", false
}
return raw, info.IconExt, true
}
func (s *Store) save(info Info) error {
if err := os.MkdirAll(s.dir, 0o755); err != nil {
return fmt.Errorf("identity: mkdir: %w", err)
}
data, err := json.MarshalIndent(info, "", " ")
if err != nil {
return fmt.Errorf("identity: encode: %w", err)
}
if err := writeFileAtomic(s.metaPath(), data, 0o644); err != nil {
return fmt.Errorf("identity: write: %w", err)
}
return nil
}
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
tmp := path + ".tmp"
if err := os.WriteFile(tmp, data, perm); err != nil {
return err
}
return os.Rename(tmp, path)
}

View file

@ -0,0 +1,95 @@
package identity
import (
"os"
"path/filepath"
"testing"
)
func TestStoreTextRoundTrip(t *testing.T) {
s := NewStore(t.TempDir())
info, err := s.Get()
if err != nil {
t.Fatal(err)
}
if info != (Info{}) {
t.Fatalf("expected zero-value Info before any write, got %+v", info)
}
if err := s.SetText(" OwpenGram ", " A self-hosted server. "); err != nil {
t.Fatal(err)
}
info, err = s.Get()
if err != nil {
t.Fatal(err)
}
if info.Name != "OwpenGram" || info.Description != "A self-hosted server." {
t.Fatalf("got %+v", info)
}
}
func TestStoreIconRoundTrip(t *testing.T) {
dir := t.TempDir()
s := NewStore(dir)
if _, _, ok := s.Icon(); ok {
t.Fatal("expected no icon before any upload")
}
png := []byte{0x89, 'P', 'N', 'G', 1, 2, 3}
if err := s.SetIcon(png, ".png"); err != nil {
t.Fatal(err)
}
data, ext, ok := s.Icon()
if !ok || ext != ".png" || string(data) != string(png) {
t.Fatalf("Icon() = %v, %q, %v", data, ext, ok)
}
if _, err := os.Stat(filepath.Join(dir, "icon.png")); err != nil {
t.Fatal(err)
}
// Replacing with a different extension removes the old file.
jpg := []byte{0xFF, 0xD8, 0xFF}
if err := s.SetIcon(jpg, ".jpg"); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(filepath.Join(dir, "icon.png")); !os.IsNotExist(err) {
t.Fatal("old icon.png should have been removed")
}
data, ext, ok = s.Icon()
if !ok || ext != ".jpg" || string(data) != string(jpg) {
t.Fatalf("Icon() after replace = %v, %q, %v", data, ext, ok)
}
if err := s.RemoveIcon(); err != nil {
t.Fatal(err)
}
if _, _, ok := s.Icon(); ok {
t.Fatal("expected no icon after RemoveIcon")
}
// Name/description set earlier (none here) must survive icon churn --
// Get() after all this should still report a clean, non-error zero name.
info, err := s.Get()
if err != nil {
t.Fatal(err)
}
if info.IconExt != "" {
t.Fatalf("IconExt should be empty after removal, got %q", info.IconExt)
}
}
func TestStorePreservesIconAcrossTextEdits(t *testing.T) {
s := NewStore(t.TempDir())
if err := s.SetIcon([]byte{1, 2, 3}, ".webp"); err != nil {
t.Fatal(err)
}
if err := s.SetText("New Name", "New description"); err != nil {
t.Fatal(err)
}
_, ext, ok := s.Icon()
if !ok || ext != ".webp" {
t.Fatalf("icon lost after unrelated SetText: ext=%q ok=%v", ext, ok)
}
}

View file

@ -26,6 +26,7 @@ import (
"github.com/iamxvbaba/td/transport"
"github.com/iamxvbaba/td/tlprofile"
"telesrv/internal/identity"
"telesrv/internal/store"
"telesrv/internal/store/memory"
)
@ -343,6 +344,11 @@ type Options struct {
StrictDC bool
// RSAKey 是 server RSA 私钥用于密钥交换。nil 时无法完成握手。
RSAKey *rsa.PrivateKey
// IdentityDir, when non-empty, enables serving the admin-editable server
// name/description/icon over ServerInfoPath/ServerIconPath (see
// internal/identity). Empty disables the feature -- those fields are
// simply omitted, RSA key/DC info still serve as before.
IdentityDir string
// AuthKeys 持久化 auth key。默认内存实现。
AuthKeys store.AuthKeyStore
// ActiveSessions 管理活跃连接。默认新建;传入时可让 RPC 层共享同一注册表。
@ -523,6 +529,9 @@ type Server struct {
// from a host:port instead of a manual openssl + copy-paste.
pubKeyPEM []byte
// identityStore is nil when Options.IdentityDir is empty (feature off).
identityStore *identity.Store
// onFrame 是测试钩子:收到一帧时回调其字节数;生产为 nil。
onFrame func(n int)
}
@ -582,6 +591,9 @@ func New(opts Options) *Server {
rpcRewrap: newRPCRewrapRegistry(opts.RPCGlobalMaxTasks),
admission: newAdmissionController(opts.MaxConnections, opts.MaxConnectionsPerIP, opts.MaxConcurrentHandshakes),
}
if opts.IdentityDir != "" {
server.identityStore = identity.NewStore(opts.IdentityDir)
}
conns.setLogicalSessionReleaseHook(func(key sessionKey) {
server.rpcResults.forgetSession(key.authKeyID, key.sessionID)
})
@ -718,6 +730,7 @@ func (s *Server) serveMixed(ctx context.Context, ln net.Listener) error {
websocketRouteHandler(wsHandler, s.websocketOrigins),
s.dc,
s.pubKeyPEM,
s.identityStore,
),
ReadHeaderTimeout: minDuration(10*time.Second, s.handshakeTimeout),
BaseContext: func(net.Listener) context.Context {

View file

@ -5,7 +5,11 @@ import (
"crypto/x509"
"encoding/json"
"encoding/pem"
"mime"
"net/http"
"strconv"
"telesrv/internal/identity"
)
// ServerInfoPath is the well-known same-port HTTP path a client can GET to
@ -20,13 +24,26 @@ import (
// active, i.e. TELESRV_WEBSOCKET_ENABLE=true (the default).
const ServerInfoPath = "/owpengram/server-info"
// ServerIconPath serves the server's icon as a raw image, separately from
// ServerInfoPath's JSON -- avoids inflating every server-info fetch with a
// base64 blob when most callers only need it once, and lets it be cached/
// requested independently (e.g. an <img src> tag).
const ServerIconPath = "/owpengram/server-icon"
// ServerInfoResponse is ServerInfoPath's JSON body. RSAPublicKeyPEM is the
// PKCS#1 "RSA PUBLIC KEY" PEM block -- the same format
// `openssl rsa -RSAPublicKey_out` produces, and what the client's "Add
// Server" RSA key field already expects verbatim.
// Server" RSA key field already expects verbatim. Name/Description are
// admin-edited via internal/identity and optional -- clients should treat
// blank as "no override" and keep whatever the user typed.
type ServerInfoResponse struct {
DCID int `json:"dc_id"`
RSAPublicKeyPEM string `json:"rsa_public_key_pem"`
Name string `json:"name,omitempty"`
Description string `json:"description,omitempty"`
// HasIcon tells the client whether GET ServerIconPath is worth calling,
// without requiring a separate round trip just to find out.
HasIcon bool `json:"has_icon,omitempty"`
}
// rsaPublicKeyPEM renders key's public half as a PKCS#1 PEM block, matching
@ -41,34 +58,108 @@ func rsaPublicKeyPEM(key *rsa.PrivateKey) []byte {
return pem.EncodeToMemory(&pem.Block{Type: "RSA PUBLIC KEY", Bytes: der})
}
// serverInfoHTTPHandler serves ServerInfoResponse at ServerInfoPath and
// delegates every other path to next (the existing WebSocket route
// handler). pubKeyPEM is nil when the server has no RSA key configured
// (shouldn't happen in production -- handshakes would already be broken --
// but a client asking anyway gets 503, not a panic or an empty key).
func serverInfoHTTPHandler(next http.Handler, dc int, pubKeyPEM []byte) http.Handler {
// serverInfoHTTPHandler serves ServerInfoResponse at ServerInfoPath and the
// raw icon bytes at ServerIconPath, delegating every other path to next (the
// existing WebSocket route handler). pubKeyPEM is nil when the server has no
// RSA key configured (shouldn't happen in production -- handshakes would
// already be broken -- but a client asking anyway gets 503, not a panic or
// an empty key). identityStore may be nil (identity feature disabled);
// Name/Description/icon are then simply omitted, RSA key/DC still serve.
func serverInfoHTTPHandler(
next http.Handler,
dc int,
pubKeyPEM []byte,
identityStore *identity.Store,
) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != ServerInfoPath {
switch r.URL.Path {
case ServerInfoPath:
serveServerInfo(w, r, dc, pubKeyPEM, identityStore)
case ServerIconPath:
serveServerIcon(w, r, identityStore)
default:
next.ServeHTTP(w, r)
return
}
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if len(pubKeyPEM) == 0 {
http.Error(w, "server key not configured", http.StatusServiceUnavailable)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
if r.Method == http.MethodHead {
return
}
_ = json.NewEncoder(w).Encode(ServerInfoResponse{
DCID: dc,
RSAPublicKeyPEM: string(pubKeyPEM),
})
})
}
func serveServerInfo(
w http.ResponseWriter,
r *http.Request,
dc int,
pubKeyPEM []byte,
identityStore *identity.Store,
) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if len(pubKeyPEM) == 0 {
http.Error(w, "server key not configured", http.StatusServiceUnavailable)
return
}
resp := ServerInfoResponse{
DCID: dc,
RSAPublicKeyPEM: string(pubKeyPEM),
}
if identityStore != nil {
if info, err := identityStore.Get(); err == nil {
resp.Name = info.Name
resp.Description = info.Description
resp.HasIcon = info.IconExt != ""
}
}
body, err := json.Marshal(resp)
if err != nil {
http.Error(w, "encode server info", http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
// See serveServerIcon's identical Content-Length comment -- same reason:
// keeps net/http from chunked-encoding a response the desktop client's
// raw-socket parser can't decode. name/description are short today, but
// nothing enforces that server-side, so this isn't purely defensive.
w.Header().Set("Content-Length", strconv.Itoa(len(body)))
if r.Method == http.MethodHead {
return
}
_, _ = w.Write(body)
}
func serveServerIcon(w http.ResponseWriter, r *http.Request, identityStore *identity.Store) {
if r.Method != http.MethodGet && r.Method != http.MethodHead {
w.Header().Set("Allow", "GET, HEAD")
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
if identityStore == nil {
http.NotFound(w, r)
return
}
data, ext, ok := identityStore.Icon()
if !ok {
http.NotFound(w, r)
return
}
contentType := mime.TypeByExtension(ext)
if contentType == "" {
contentType = "application/octet-stream"
}
w.Header().Set("Content-Type", contentType)
w.Header().Set("Cache-Control", "no-store")
// Explicit Content-Length keeps net/http from switching to
// Transfer-Encoding: chunked, which it otherwise does automatically once
// a single Write() exceeds its small internal sniff buffer (true for
// basically any real icon, easily hundreds of KB) -- the desktop
// client's same-port fetch is a hand-rolled raw-socket HTTP/1.1 parser
// (see FetchServerIcon in owpengram_servers.cpp), not a real HTTP
// client, and has no chunked-decoding logic: it would otherwise treat
// the chunk-size-prefixed framing as image bytes and fail to decode.
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
if r.Method == http.MethodHead {
return
}
_, _ = w.Write(data)
}

View file

@ -0,0 +1,105 @@
package mtprotoedge
import (
"crypto/rand"
"crypto/rsa"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"telesrv/internal/identity"
)
func TestServeServerInfoIncludesIdentity(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
pem := rsaPublicKeyPEM(key)
store := identity.NewStore(t.TempDir())
if err := store.SetText("OwpenGram", "A self-hosted server."); err != nil {
t.Fatal(err)
}
fallback := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })
h := serverInfoHTTPHandler(fallback, 2, pem, store)
req := httptest.NewRequest(http.MethodGet, ServerInfoPath, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var resp ServerInfoResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Name != "OwpenGram" || resp.Description != "A self-hosted server." {
t.Fatalf("got %+v", resp)
}
if resp.HasIcon {
t.Fatal("has_icon should be false before any upload")
}
}
func TestServeServerInfoWithoutIdentityStore(t *testing.T) {
key, err := rsa.GenerateKey(rand.Reader, 2048)
if err != nil {
t.Fatal(err)
}
pem := rsaPublicKeyPEM(key)
fallback := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })
h := serverInfoHTTPHandler(fallback, 2, pem, nil)
req := httptest.NewRequest(http.MethodGet, ServerInfoPath, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
var resp ServerInfoResponse
if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil {
t.Fatal(err)
}
if resp.Name != "" || resp.Description != "" || resp.HasIcon {
t.Fatalf("expected empty identity fields when store is nil, got %+v", resp)
}
if resp.RSAPublicKeyPEM == "" {
t.Fatal("RSA key should still serve when identity store is nil")
}
}
func TestServeServerIcon(t *testing.T) {
store := identity.NewStore(t.TempDir())
fallback := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { http.NotFound(w, r) })
h := serverInfoHTTPHandler(fallback, 2, []byte("pem"), store)
// No icon configured yet -> 404.
req := httptest.NewRequest(http.MethodGet, ServerIconPath, nil)
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 before upload", rec.Code)
}
png := []byte{0x89, 'P', 'N', 'G', 1, 2, 3, 4}
if err := store.SetIcon(png, ".png"); err != nil {
t.Fatal(err)
}
req = httptest.NewRequest(http.MethodGet, ServerIconPath, nil)
rec = httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d", rec.Code)
}
if rec.Body.String() != string(png) {
t.Fatal("icon body mismatch")
}
if ct := rec.Header().Get("Content-Type"); ct != "image/png" {
t.Fatalf("Content-Type = %q", ct)
}
}

View file

@ -0,0 +1,35 @@
package procctl
import (
"context"
"os"
"path/filepath"
"testing"
)
func TestHandlePendingAdminRestartNoop(t *testing.T) {
m := NewManager(t.TempDir())
restarted, err := m.HandlePendingAdminRestart(context.Background())
if err != nil {
t.Fatal(err)
}
if restarted {
t.Fatal("expected no-op when no restart was requested")
}
}
func TestHandlePendingAdminRestartFlagRoundTrip(t *testing.T) {
dir := t.TempDir()
statePath := filepath.Join(dir, stateFileName)
if err := os.WriteFile(statePath, []byte(`{"server_pid":123,"admin_pid":0,"pending_admin_restart":true}`), 0o644); err != nil {
t.Fatal(err)
}
m := NewManager(dir)
st := m.loadState()
if !st.PendingAdminRestart {
t.Fatal("expected PendingAdminRestart to round-trip from JSON")
}
if st.ServerPID != 123 {
t.Fatalf("ServerPID = %d, want 123 (existing fields must survive)", st.ServerPID)
}
}

View file

@ -0,0 +1,135 @@
package procctl
import (
"os"
"path/filepath"
"strings"
"testing"
)
// findRepoRoot walks up from the test's working directory looking for
// .env.example -- the same file server-panel.py parses -- so this test
// exercises the real file, not a synthetic fixture that could drift from it.
func findRepoRoot(t *testing.T) string {
t.Helper()
dir, err := os.Getwd()
if err != nil {
t.Fatal(err)
}
for {
if _, err := os.Stat(filepath.Join(dir, ".env.example")); err == nil {
return dir
}
parent := filepath.Dir(dir)
if parent == dir {
t.Fatal(".env.example not found above test directory")
}
dir = parent
}
}
func TestReadEnvGroupsParsesRealTemplate(t *testing.T) {
root := findRepoRoot(t)
m := NewManager(root)
groups, err := m.ReadEnvGroups()
if err != nil {
t.Fatal(err)
}
if len(groups) == 0 {
t.Fatal("expected at least one group from .env.example")
}
found := map[string]bool{}
for _, g := range groups {
if g.Title == "" {
t.Errorf("group with empty title, description=%q", g.Description)
}
if len(g.Fields) == 0 {
t.Errorf("group %q has no fields", g.Title)
}
for _, f := range g.Fields {
if !strings.HasPrefix(f.Key, "TELESRV_") {
t.Errorf("field key %q missing TELESRV_ prefix", f.Key)
}
found[f.Key] = true
}
}
// A couple of fields we know exist in the real file (checked above) --
// pins the parser to the actual format, not just "produces something".
for _, key := range []string{"TELESRV_LISTEN", "TELESRV_ADVERTISE_IP", "TELESRV_DC"} {
if !found[key] {
t.Errorf("expected field %s in parsed groups", key)
}
}
// The advanced/internal-tuning section (after the "# ===..." banner) is
// deliberately excluded -- mirrors server-panel.py's parse_env_template.
if found["TELESRV_MTPROTO_RPC_MAX_INFLIGHT"] {
t.Error("advanced/internal field leaked into panel-visible groups")
}
}
func TestWriteEnvValuesRoundTrip(t *testing.T) {
root := findRepoRoot(t)
tmpl, err := os.ReadFile(filepath.Join(root, ".env.example"))
if err != nil {
t.Fatal(err)
}
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, ".env.example"), tmpl, 0o644); err != nil {
t.Fatal(err)
}
m := NewManager(dir)
groups, err := m.ReadEnvGroups()
if err != nil {
t.Fatal(err)
}
if len(groups) == 0 {
t.Fatal("no groups parsed")
}
if err := m.WriteEnvValues(map[string]string{
"TELESRV_ADVERTISE_IP": "203.0.113.5",
"TELESRV_DC": "3",
}); err != nil {
t.Fatal(err)
}
groups2, err := m.ReadEnvGroups()
if err != nil {
t.Fatal(err)
}
values := map[string]string{}
for _, g := range groups2 {
for _, f := range g.Fields {
values[f.Key] = f.Value
}
}
if values["TELESRV_ADVERTISE_IP"] != "203.0.113.5" {
t.Errorf("TELESRV_ADVERTISE_IP = %q, want 203.0.113.5", values["TELESRV_ADVERTISE_IP"])
}
if values["TELESRV_DC"] != "3" {
t.Errorf("TELESRV_DC = %q, want 3", values["TELESRV_DC"])
}
// A field never touched by WriteEnvValues should still read back its
// template default -- confirms the file's other lines/comments/layout
// survived the rewrite untouched.
if values["TELESRV_LISTEN"] == "" {
t.Error("TELESRV_LISTEN lost its default after an unrelated field was written")
}
envPath := filepath.Join(dir, ".env")
envData, err := os.ReadFile(envPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(envData), "TELESRV_ADVERTISE_IP=203.0.113.5") {
t.Error(".env does not contain the written value verbatim")
}
// The file's descriptive comments (the reason server-panel.py rewrites
// from the template instead of dumping bare key=value pairs) must
// survive too.
if !strings.Contains(string(envData), "Your server's public IP address.") {
t.Error(".env lost .env.example's comment lines on write")
}
}

581
internal/procctl/procctl.go Normal file
View file

@ -0,0 +1,581 @@
// Package procctl mirrors the process-management half of tui-panel/
// server-panel.py (git pull, go build, launch/stop, and the shared
// .server_panel.json PID state file) so the admin web panel can offer the
// same Restart/Update actions the TUI already has, without requiring an
// operator to SSH in and use the TUI for that specifically.
//
// Restart/Update never restart the admin binary themselves (the process
// this code typically runs inside, when called from cmd/telesrv-admin) --
// self-restarting mid-HTTP-request is a materially different, riskier
// problem (dropped response, no clean signal to the caller that it actually
// completed) than the TUI's case, where a human is watching an interactive
// session and re-exec is transparent. Instead they set
// State.PendingAdminRestart and let the *next* owpengram-server process
// pick it up via HandlePendingAdminRestart once it's confirmed serving
// (cmd/telesrv/main.go's OnServing hook) -- that process is unrelated to
// whatever admin panel is currently running, so it can safely kill the old
// admin PID and launch a new one with none of the self-restart risk.
package procctl
import (
"context"
"encoding/json"
"fmt"
"os"
"os/exec"
"path/filepath"
"regexp"
"runtime"
"strconv"
"strings"
"time"
)
const stateFileName = ".server_panel.json"
// Manager operates on one repo checkout (Root), the same layout
// tui-panel/server-panel.py expects: bin/, logs/, .env, .env.example, and
// .server_panel.json at the root.
type Manager struct {
Root string
}
func NewManager(root string) *Manager {
return &Manager{Root: root}
}
func (m *Manager) serverExe() string {
if runtime.GOOS == "windows" {
return filepath.Join(m.Root, "bin", "owpengram-server.exe")
}
return filepath.Join(m.Root, "bin", "owpengram-server")
}
func (m *Manager) adminExe() string {
if runtime.GOOS == "windows" {
return filepath.Join(m.Root, "bin", "owpengram-admin-panel.exe")
}
return filepath.Join(m.Root, "bin", "owpengram-admin-panel")
}
func (m *Manager) serverLog() string { return filepath.Join(m.Root, "logs", "owpengram-server.log") }
func (m *Manager) adminLog() string {
return filepath.Join(m.Root, "logs", "owpengram-admin-panel.log")
}
// --- state file (shared with tui-panel/server-panel.py) --------------------
type State struct {
ServerPID int `json:"server_pid"`
AdminPID int `json:"admin_pid"`
DockerProject string `json:"docker_project"`
DockerPrefix string `json:"docker_prefix"`
// PendingAdminRestart is how Restart/Update ask the *next*
// owpengram-server process to bounce the admin panel for them, instead
// of the admin panel trying to restart itself mid-HTTP-request (see the
// package doc). Set here, consumed by HandlePendingAdminRestart at the
// new owpengram-server's startup. server-panel.py doesn't know this key
// exists -- its own save_state() overwrites the file with only its 4
// original fields, so a Stop/Start/Restart/Update run from the TUI in
// the narrow window before the flag is consumed will silently drop it.
// Rare, and the only consequence is the admin panel not restarting that
// one time -- not worth coordinating two processes' writes over.
PendingAdminRestart bool `json:"pending_admin_restart,omitempty"`
}
func (m *Manager) loadState() State {
var st State
data, err := os.ReadFile(filepath.Join(m.Root, stateFileName))
if err != nil {
return st
}
_ = json.Unmarshal(data, &st)
if st.DockerProject == "" {
st.DockerProject = "owpengram"
}
if st.DockerPrefix == "" {
st.DockerPrefix = "owpengram"
}
return st
}
func (m *Manager) saveState(st State) error {
data, err := json.Marshal(st)
if err != nil {
return err
}
return os.WriteFile(filepath.Join(m.Root, stateFileName), data, 0o644)
}
// Status reports whether the server/admin PIDs recorded in the shared state
// file are still alive.
type Status struct {
ServerPID int
ServerAlive bool
AdminPID int
AdminAlive bool
}
func (m *Manager) Status() Status {
st := m.loadState()
return Status{
ServerPID: st.ServerPID,
ServerAlive: pidAlive(st.ServerPID),
AdminPID: st.AdminPID,
AdminAlive: pidAlive(st.AdminPID),
}
}
// --- process control ---------------------------------------------------
func pidAlive(pid int) bool {
if pid <= 0 {
return false
}
if runtime.GOOS == "windows" {
out, err := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid)).Output()
if err != nil {
return false
}
return strings.Contains(string(out), strconv.Itoa(pid))
}
return exec.Command("kill", "-0", strconv.Itoa(pid)).Run() == nil
}
// killPID mirrors kill_pid() in server-panel.py, MINUS its "/T" tree-kill on
// Windows -- deliberately different here, not an oversight. server-panel.py
// is always the common parent of both the server and admin processes, so
// tree-killing one PID never touches the other. This package's callers are
// not: HandlePendingAdminRestart runs *inside* the freshly launched
// owpengram-server, which was itself spawned as a child of the *old*
// owpengram-admin-panel process by the Restart/Update call that got it
// here. Killing that old admin PID with "/T" would tree-kill its entire
// descendant chain -- including this very owpengram-server process, since
// it's a child of the PID being killed. Windows' taskkill walks that chain
// by recorded parent-PID regardless of any process-group flags on launch,
// so the only reliable fix is to never tree-kill here: exact-PID kill only,
// since every process this package launches is started directly via
// exec.Command (no intermediate shell wrapper), so there's no wrapper-spawned
// grandchild "/T" would need to catch anyway.
func killPID(pid int) {
if pid <= 0 {
return
}
if runtime.GOOS == "windows" {
_ = exec.Command("taskkill", "/PID", strconv.Itoa(pid), "/F").Run()
return
}
_ = exec.Command("kill", "-TERM", strconv.Itoa(pid)).Run()
time.Sleep(time.Second)
_ = exec.Command("kill", "-KILL", strconv.Itoa(pid)).Run()
}
// launch starts exePath detached, cwd=Root, stdout/stderr appended to
// logPath, and returns its PID. Unlike the Python TUI this does not set a
// new session/process group (that needs OS-specific SysProcAttr) -- started
// via Start() (not Run()), the child outlives this function's return either
// way, which is all a request/response HTTP handler needs.
func (m *Manager) launch(exePath, logPath string) (int, error) {
if err := os.MkdirAll(filepath.Dir(logPath), 0o755); err != nil {
return 0, fmt.Errorf("mkdir logs: %w", err)
}
logf, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
if err != nil {
return 0, fmt.Errorf("open log: %w", err)
}
defer logf.Close()
cmd := exec.Command(exePath)
cmd.Dir = m.Root
cmd.Stdout = logf
cmd.Stderr = logf
cmd.Stdin = nil
if err := cmd.Start(); err != nil {
return 0, fmt.Errorf("start %s: %w", exePath, err)
}
go func() { _ = cmd.Wait() }() // reap so it doesn't linger as a zombie
return cmd.Process.Pid, nil
}
// --- Docker infrastructure (Postgres/Redis/MinIO) -------------------------
const (
postgresWaitTimeout = 60 * time.Second
postgresWaitInterval = 2 * time.Second
)
// ensureDocker mirrors server-panel.py's START_STEPS "docker" + "postgres"
// steps -- `docker compose up -d` then wait for Postgres to answer
// pg_isready. Restart/Update run this every time, same as the TUI: it's a
// no-op when the containers are already up (compose up -d on a running
// stack just confirms state), but skipping it entirely was the actual bug
// report this addresses -- a Restart/Update landing while Postgres/Redis/
// MinIO are down (host reboot, containers manually stopped, etc.) would
// otherwise relaunch owpengram-server straight into a DB-connect failure
// with no clear signal why, instead of surfacing "Postgres not ready" here.
func (m *Manager) ensureDocker(ctx context.Context, st State) (string, error) {
composeFile := filepath.Join(m.Root, "deploy", "docker-compose.yml")
if _, err := os.Stat(composeFile); os.IsNotExist(err) {
return "", nil
}
cmd := exec.CommandContext(ctx, "docker", "compose", "-f", composeFile, "up", "-d")
cmd.Dir = m.Root
cmd.Env = append(os.Environ(),
"TELESRV_DOCKER_PROJECT="+st.DockerProject,
"TELESRV_DOCKER_PREFIX="+st.DockerPrefix,
)
out, err := cmd.CombinedOutput()
log := "$ docker compose up -d\n" + string(out)
if err != nil {
return log, fmt.Errorf("docker compose up failed: %w", err)
}
deadline := time.Now().Add(postgresWaitTimeout)
for {
pgCmd := exec.CommandContext(ctx, "docker", "exec", st.DockerPrefix+"-postgres", "pg_isready", "-U", "telesrv", "-d", "telesrv")
if pgCmd.Run() == nil {
return log + "\nPostgreSQL ready\n", nil
}
if time.Now().After(deadline) {
return log, fmt.Errorf("PostgreSQL not ready after %s", postgresWaitTimeout)
}
select {
case <-ctx.Done():
return log, ctx.Err()
case <-time.After(postgresWaitInterval):
}
}
}
// --- build steps ---------------------------------------------------------
// GitPull runs `git pull --ff-only`, deliberately never a real merge -- see
// the identical reasoning in server-panel.py's git_pull().
func (m *Manager) GitPull(ctx context.Context) (string, error) {
cmd := exec.CommandContext(ctx, "git", "pull", "--ff-only")
cmd.Dir = m.Root
out, err := cmd.CombinedOutput()
log := "$ git pull --ff-only\n" + string(out)
return log, err
}
// buildServer builds only bin/owpengram-server.
func (m *Manager) buildServer(ctx context.Context) (string, error) {
return m.goBuild(ctx, m.serverExe(), "./cmd/telesrv")
}
// buildBoth builds bin/owpengram-server and bin/owpengram-admin-panel, like
// server-panel.py's build(). Used by Update, which leaves a fresh admin
// binary on disk even though it doesn't self-restart into it (see package
// doc).
func (m *Manager) buildBoth(ctx context.Context) (string, error) {
serverLog, err := m.buildServer(ctx)
if err != nil {
return serverLog, err
}
adminLog, err := m.goBuild(ctx, m.adminExe(), "./cmd/telesrv-admin")
return serverLog + "\n" + adminLog, err
}
func (m *Manager) goBuild(ctx context.Context, outPath, pkg string) (string, error) {
if err := os.MkdirAll(filepath.Dir(outPath), 0o755); err != nil {
return "", fmt.Errorf("mkdir bin: %w", err)
}
cmd := exec.CommandContext(ctx, "go", "build", "-o", outPath, pkg)
cmd.Dir = m.Root
out, err := cmd.CombinedOutput()
return fmt.Sprintf("$ go build -o %s %s\n%s", filepath.Base(outPath), pkg, string(out)), err
}
// --- high-level actions ----------------------------------------------------
// Restart rebuilds and relaunches only bin/owpengram-server (the MTProto
// data-plane process) -- never the admin binary currently handling this
// request. Returns a combined build/relaunch log for the admin UI.
func (m *Manager) Restart(ctx context.Context) (string, error) {
st := m.loadState()
if pidAlive(st.ServerPID) {
killPID(st.ServerPID)
}
dockerLog, err := m.ensureDocker(ctx, st)
if err != nil {
return dockerLog, err
}
buildLog, err := m.buildServer(ctx)
fullLog := dockerLog + "\n" + buildLog
if err != nil {
return fullLog, fmt.Errorf("build failed: %w", err)
}
pid, err := m.launch(m.serverExe(), m.serverLog())
if err != nil {
return fullLog, fmt.Errorf("launch failed: %w", err)
}
st.ServerPID = pid
// Ask the process we just launched to bounce the admin panel for us
// once it's up -- see HandlePendingAdminRestart's doc comment for why
// that's the safe side of this handoff to do it from.
st.PendingAdminRestart = true
if err := m.saveState(st); err != nil {
return fullLog, fmt.Errorf("save state: %w", err)
}
return fullLog + fmt.Sprintf("\nowpengram-server relaunched, pid=%d. Admin panel will restart shortly.\n", pid), nil
}
// Update runs git pull, ensures Docker infrastructure is up, rebuilds both
// binaries, then does the same server-only relaunch as Restart -- including
// asking that new process to bounce the admin panel too, now onto its
// freshly built binary.
func (m *Manager) Update(ctx context.Context) (string, error) {
pullLog, err := m.GitPull(ctx)
if err != nil {
return pullLog, fmt.Errorf("git pull failed: %w", err)
}
st := m.loadState()
if pidAlive(st.ServerPID) {
killPID(st.ServerPID)
}
dockerLog, err := m.ensureDocker(ctx, st)
fullLog := pullLog + "\n" + dockerLog
if err != nil {
return fullLog, err
}
buildLog, err := m.buildBoth(ctx)
fullLog = fullLog + "\n" + buildLog
if err != nil {
return fullLog, fmt.Errorf("build failed: %w", err)
}
pid, err := m.launch(m.serverExe(), m.serverLog())
if err != nil {
return fullLog, fmt.Errorf("launch failed: %w", err)
}
st.ServerPID = pid
st.PendingAdminRestart = true
if err := m.saveState(st); err != nil {
return fullLog, fmt.Errorf("save state: %w", err)
}
return fullLog + fmt.Sprintf("\nowpengram-server relaunched, pid=%d. Admin panel will restart shortly onto its freshly built binary.\n", pid), nil
}
// HandlePendingAdminRestart is called once by owpengram-server itself, right
// after it confirms it's up and serving (see cmd/telesrv/main.go's
// OnServing hook) -- never by the admin panel on itself. That ordering is
// the whole point: by the time this runs, the *new* owpengram-server
// process already exists and is unrelated to whatever admin panel process
// is currently running, so killing the old admin PID and launching a new
// one here carries none of the risk self-restarting mid-HTTP-request would
// (see the package doc). A no-op when no restart was requested.
func (m *Manager) HandlePendingAdminRestart(ctx context.Context) (bool, error) {
st := m.loadState()
if !st.PendingAdminRestart {
return false, nil
}
if pidAlive(st.AdminPID) {
killPID(st.AdminPID)
}
pid, err := m.launch(m.adminExe(), m.adminLog())
if err != nil {
return false, fmt.Errorf("launch admin panel: %w", err)
}
st.AdminPID = pid
st.PendingAdminRestart = false
if err := m.saveState(st); err != nil {
return true, fmt.Errorf("save state: %w", err)
}
return true, nil
}
// --- .env.example / .env editing -------------------------------------------
var (
activeFieldRe = regexp.MustCompile(`^(TELESRV_[A-Z0-9_]+)=(.*)$`)
commentedFieldRe = regexp.MustCompile(`^#\s*(TELESRV_[A-Z0-9_]+)=(.*)$`)
sensitiveKeyRe = regexp.MustCompile(`(PASSWORD|SECRET|_TOKEN|API_KEY)`)
groupHeaderRe = regexp.MustCompile(`^##\s*(.+?)\s*--\s*(.+)$`)
sectionBreakRe = regexp.MustCompile(`^#\s*={10,}\s*$`)
)
type EnvField struct {
Key string `json:"key"`
DefaultValue string `json:"default_value"`
Description string `json:"description"`
EnabledByDefault bool `json:"enabled_by_default"`
Sensitive bool `json:"sensitive"`
// Value is the field's current effective value: from .env when set,
// otherwise DefaultValue (only when EnabledByDefault), else empty --
// exactly current_env_values()'s semantics in server-panel.py.
Value string `json:"value"`
}
type EnvGroup struct {
Title string `json:"title"`
Description string `json:"description"`
Fields []EnvField `json:"fields"`
}
// ReadEnvGroups parses .env.example into the same panel-visible groups
// server-panel.py's parse_env_template() does (identical header/format
// rules -- see that function's docstring), then fills in each field's
// current effective value from .env.
func (m *Manager) ReadEnvGroups() ([]EnvGroup, error) {
tmplPath := filepath.Join(m.Root, ".env.example")
tmplData, err := os.ReadFile(tmplPath)
if os.IsNotExist(err) {
return nil, nil
}
if err != nil {
return nil, fmt.Errorf("read .env.example: %w", err)
}
envValues, err := m.readEnvFile()
if err != nil {
return nil, err
}
var groups []EnvGroup
var current *EnvGroup
var pending []string
inCommentRun := false
seen := map[string]bool{}
appendField := func(key, defaultValue, description string, enabledByDefault bool) {
if current == nil || seen[key] {
return
}
seen[key] = true
value, has := envValues[key]
if !has {
if enabledByDefault {
value = defaultValue
} else {
value = ""
}
}
current.Fields = append(current.Fields, EnvField{
Key: key,
DefaultValue: defaultValue,
Description: description,
EnabledByDefault: enabledByDefault,
Sensitive: sensitiveKeyRe.MatchString(key),
Value: value,
})
}
for _, raw := range strings.Split(string(tmplData), "\n") {
line := strings.TrimSpace(raw)
if line == "" {
pending = nil
inCommentRun = false
continue
}
if h := groupHeaderRe.FindStringSubmatch(line); h != nil {
groups = append(groups, EnvGroup{Title: strings.TrimSpace(h[1]), Description: strings.TrimSpace(h[2])})
current = &groups[len(groups)-1]
pending = nil
inCommentRun = false
continue
}
if sectionBreakRe.MatchString(line) {
current = nil
pending = nil
inCommentRun = false
continue
}
if a := activeFieldRe.FindStringSubmatch(line); a != nil {
appendField(a[1], a[2], strings.Join(pending, " "), true)
inCommentRun = false
continue
}
if strings.HasPrefix(line, "#") {
if c := commentedFieldRe.FindStringSubmatch(line); c != nil {
appendField(c[1], c[2], strings.Join(pending, " "), false)
inCommentRun = false
continue
}
text := strings.TrimSpace(strings.TrimLeft(line, "#"))
if inCommentRun {
pending = append(pending, text)
} else {
pending = []string{text}
}
inCommentRun = true
continue
}
inCommentRun = false
}
out := groups[:0]
for _, g := range groups {
if len(g.Fields) > 0 {
out = append(out, g)
}
}
return out, nil
}
func (m *Manager) readEnvFile() (map[string]string, error) {
values := map[string]string{}
data, err := os.ReadFile(filepath.Join(m.Root, ".env"))
if os.IsNotExist(err) {
return values, nil
}
if err != nil {
return nil, fmt.Errorf("read .env: %w", err)
}
for _, raw := range strings.Split(string(data), "\n") {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
idx := strings.IndexByte(line, '=')
if idx <= 0 {
continue
}
values[line[:idx]] = strings.TrimSpace(line[idx+1:])
}
return values, nil
}
// WriteEnvValues rewrites .env from .env.example's exact text, substituting
// each known key's value in place -- see save_env()'s docstring in
// server-panel.py for why this (not a fresh key=value dump) is what
// preserves comments/layout. Only keys present in values are touched; a
// template-commented optional field is uncommented when given a non-empty
// value and left as-is when given an empty one.
func (m *Manager) WriteEnvValues(values map[string]string) error {
tmplPath := filepath.Join(m.Root, ".env.example")
tmplData, err := os.ReadFile(tmplPath)
if err != nil {
return fmt.Errorf("read .env.example: %w", err)
}
lines := strings.Split(string(tmplData), "\n")
// Split() on a trailing "\n" leaves one empty trailing element; drop it
// so the join below doesn't add a spurious blank line before the final
// newline this function appends anyway.
if len(lines) > 0 && lines[len(lines)-1] == "" {
lines = lines[:len(lines)-1]
}
out := make([]string, 0, len(lines))
seen := map[string]bool{}
for _, raw := range lines {
line := strings.TrimSpace(raw)
if a := activeFieldRe.FindStringSubmatch(line); a != nil {
if v, ok := values[a[1]]; ok && !seen[a[1]] {
seen[a[1]] = true
out = append(out, a[1]+"="+v)
continue
}
}
if c := commentedFieldRe.FindStringSubmatch(line); c != nil {
if v, ok := values[c[1]]; ok && !seen[c[1]] {
seen[c[1]] = true
if v != "" {
out = append(out, c[1]+"="+v)
} else {
out = append(out, raw)
}
continue
}
}
out = append(out, raw)
}
return os.WriteFile(filepath.Join(m.Root, ".env"), []byte(strings.Join(out, "\n")+"\n"), 0o644)
}