Feat(go): add Signer interface + License Server for multi-customer deployments

This commit is contained in:
yuanyuanxiang
2026-05-20 15:11:32 +02:00
parent e264e092f6
commit d808462fe1
14 changed files with 1798 additions and 29 deletions

View File

@@ -1,9 +1,11 @@
package main
import (
"context"
"encoding/binary"
"flag"
"fmt"
"net/http"
"os"
"os/signal"
"strconv"
@@ -14,6 +16,7 @@ import (
"github.com/yuanyuanxiang/SimpleRemoter/server/go/auth"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/connection"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/hub"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/licensing"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/logger"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/protocol"
"github.com/yuanyuanxiang/SimpleRemoter/server/go/server"
@@ -23,11 +26,11 @@ import (
// MyHandler implements the server.Handler interface
type MyHandler struct {
log *logger.Logger
auth *auth.Authenticator
srv *server.Server
hub *hub.Hub
signPwd string // HMAC key for CMD_MASTERSETTING signatures (YAMA_SIGN_PASSWORD)
log *logger.Logger
auth *auth.Authenticator
srv *server.Server
hub *hub.Hub
signer licensing.Signer // CMD_MASTERSETTING signer (local / remote / noop)
}
// OnConnect is called when a client connects
@@ -436,10 +439,14 @@ func (h *MyHandler) handleLogin(ctx *connection.Context, data []byte) {
// sendMasterSetting builds the 1001-byte CMD_MASTERSETTING reply and ships it
// down the main TCP connection. Most fields stay zeroed — only Signature
// matters today. If no signing password is configured, a zeroed signature is
// still sent (and logged once) so the client at least sees a well-formed
// packet; in that case the client's private library will refuse to start
// screen / file features and abort.
// matters today. The signer is one of:
// - LocalSigner: HMAC directly with master key (operator's own deployment)
// - RemoteSigner: HTTPS POST to operator's License Server (customer deployment)
// - NoOpSigner: returns empty signature (free tier; client refuses screen/file ops)
//
// On signer error (License Server unreachable + no cache hit), we still ship
// a zeroed signature so the packet is well-formed; the client will retry on
// next reconnect.
func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, clientID string) {
buf := make([]byte, 1+protocol.MasterSettingsSize)
buf[0] = protocol.CmdMasterSetting
@@ -451,12 +458,14 @@ func (h *MyHandler) sendMasterSetting(ctx *connection.Context, startTime, client
buf[1:5],
uint32(protocol.DefaultReportIntervalSec))
if h.signPwd == "" {
h.log.Warn("YAMA_SIGN_PASSWORD not set — client may abort on screen/file ops")
sig, err := h.signer.Sign(startTime, clientID)
if err != nil {
h.log.Error("signer (%s) failed for clientID=%s: %v — sending zeroed signature",
h.signer.Mode(), clientID, err)
} else if sig == "" {
// NoOpSigner path, or LocalSigner with empty master key — same effect.
// Log only once per process via the startup banner; don't spam here.
} else {
msg := startTime + "|" + clientID
sig := protocol.SignMessage(h.signPwd, []byte(msg))
// Signature[64] lives at offset 508 of the struct, +1 for the cmd byte.
const sigOffset = 1 + protocol.MasterSettingsOffSignature
copy(buf[sigOffset:sigOffset+protocol.MasterSettingsSignatureLen], []byte(sig))
}
@@ -676,14 +685,37 @@ func main() {
// the HTTP server reads from it.
deviceHub := hub.New()
// HMAC key used to sign the per-login CMD_MASTERSETTING reply. The
// client verifies this signature before enabling its screen / file
// features and aborts the process on mismatch. Kept in an env var so
// the literal stays out of the binary; provision out-of-band and
// never commit it.
signPwd := os.Getenv("YAMA_SIGN_PASSWORD")
if signPwd == "" {
log.Warn("YAMA_SIGN_PASSWORD not set; clients will refuse screen/file ops")
// Build the CMD_MASTERSETTING signer based on env vars:
// - YAMA_SIGN_PASSWORD set → LocalSigner (operator's own deployment;
// HMAC master key lives here)
// - YAMA_LICENSE_SERVER + YAMA_LICENSE_TOKEN set → RemoteSigner
// (customer deployment; never sees the master key, fetches signatures
// from operator's License Server with 24h cache)
// - neither → NoOpSigner (free tier; client refuses screen/file ops
// but device list still works)
signer, mode, err := licensing.NewFromEnv(log)
if err != nil {
log.Fatal("Failed to initialize signer: %v", err)
}
// signer.Close() is called explicitly after the License Server HTTP is
// drained at shutdown — sequencing matters because an in-flight
// handleSign on the HTTP path needs a live signer to complete.
switch mode {
case licensing.ModeLocal:
log.Info("Signer mode: LOCAL (operator deployment, master key held in-process)")
case licensing.ModeRemote:
log.Info("Signer mode: REMOTE (customer deployment, %s=%s)",
licensing.EnvLicenseServer, os.Getenv(licensing.EnvLicenseServer))
case licensing.ModeNoOp:
log.Warn("Signer mode: NOOP (no licensing configured; screen/file features disabled on clients)")
}
// If the operator also wants this LocalSigner deployment to serve as
// License Server for RemoteSigner customers, YAMA_LICENSE_PUBLIC_KEY +
// YAMA_LICENSE_HTTP_ADDR enable it. Off by default.
licSrv, licAddr, err := licensing.LicenseServerFromEnv(signer, log)
if err != nil {
log.Fatal("Failed to initialize License Server: %v", err)
}
// Web user authenticator. Bootstrap admin from env var YAMA_WEB_ADMIN_PASS;
@@ -726,11 +758,11 @@ func main() {
// Create handler for this server
handler := &MyHandler{
log: log.WithPrefix(fmt.Sprintf("Handler:%d", port)),
auth: authenticator,
srv: srv,
hub: deviceHub,
signPwd: signPwd,
log: log.WithPrefix(fmt.Sprintf("Handler:%d", port)),
auth: authenticator,
srv: srv,
hub: deviceHub,
signer: signer,
}
srv.SetHandler(handler)
@@ -785,10 +817,42 @@ func main() {
log.Fatal("Failed to start HTTP server: %v", err)
}
// Optionally serve the License Server HTTP endpoints in this process.
// Only active when the operator set YAMA_LICENSE_PUBLIC_KEY +
// YAMA_LICENSE_HTTP_ADDR. We serve plain HTTP — operator should put
// nginx/Caddy in front for TLS. (RemoteSigner customers connect to
// the public URL configured via YAMA_LICENSE_SERVER on their side.)
//
// Timeouts cap Slowloris / FD-exhaustion attacks. Values are generous
// enough for a slow public link (TLS-terminating proxy in front, real
// customer round-trips at ~hundreds of ms) but tight enough that a
// trickle-byte attacker cannot pin a connection indefinitely.
var licenseHTTP *http.Server
if licSrv != nil {
licenseHTTP = &http.Server{
Addr: licAddr,
Handler: licSrv.Handler(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 15 * time.Second,
WriteTimeout: 15 * time.Second,
IdleTimeout: 60 * time.Second,
MaxHeaderBytes: 16 << 10,
}
go func() {
log.Info("License Server listening on %s (POST /license/sign, /license/heartbeat)", licAddr)
if err := licenseHTTP.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Error("License Server stopped: %v", err)
}
}()
}
fmt.Printf("Server started on port(s): %v\n", ports)
if *httpPort != 0 {
fmt.Printf("Web UI on http://localhost:%d/\n", *httpPort)
}
if licenseHTTP != nil {
fmt.Printf("License Server on http://%s/license/{sign,heartbeat}\n", licAddr)
}
fmt.Println("Logs are written to: logs/server.log")
fmt.Println("Press Ctrl+C to stop...")
@@ -798,6 +862,17 @@ func main() {
<-sigChan
fmt.Println("\nShutting down...")
// Order matters: drain License Server HTTP first so no handleSign is
// mid-flight; THEN close the signer (which may release HTTP keepalives
// in RemoteSigner mode, or be a no-op for LocalSigner/NoOp).
if licenseHTTP != nil {
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
_ = licenseHTTP.Shutdown(shutdownCtx)
cancel()
}
if err := signer.Close(); err != nil {
log.Warn("signer Close: %v", err)
}
httpSrv.Stop()
for _, srv := range servers {
srv.Stop()