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

@ -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)
}
}