Compare commits
4
Commits
v1.1.0-alpha.25
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
25fa6a488a | ||
|
|
78dacb7e17 | ||
|
|
c2c8336438 | ||
|
|
63c8ef0b8a |
@@ -104,6 +104,20 @@ jobs:
|
||||
working-directory: artifacts
|
||||
run: sha256sum * > SHA256SUMS.txt
|
||||
|
||||
- name: sign the checksums (RSA-2048/SHA-256)
|
||||
env:
|
||||
KEY: ${{ secrets.SCIMESH_SIGNING_KEY }}
|
||||
working-directory: artifacts
|
||||
run: |
|
||||
if [ -n "$KEY" ]; then
|
||||
printf '%s\n' "$KEY" > /tmp/scimesh-sign-key.pem
|
||||
openssl dgst -sha256 -sign /tmp/scimesh-sign-key.pem \
|
||||
-out SHA256SUMS.txt.sig SHA256SUMS.txt
|
||||
echo "signed SHA256SUMS.txt"
|
||||
else
|
||||
echo "SCIMESH_SIGNING_KEY is not set; releasing without a signature"
|
||||
fi
|
||||
|
||||
- uses: softprops/action-gh-release@v2
|
||||
with:
|
||||
files: |
|
||||
|
||||
@@ -63,6 +63,14 @@ powershell -ExecutionPolicy Bypass -c "irm https://raw.githubusercontent.com/emi
|
||||
Set `SCIMESH_AUTO_START=0` to install without starting anything. The old demo
|
||||
control room was removed: `/ui` is the admin console.
|
||||
|
||||
**HTTPS (TLS):** serve can encrypt everything with a self-signed certificate —
|
||||
`coordinator serve --tls-autogen` generates one into the data directory and
|
||||
prints its fingerprint; workers trust it via `SCIMESH_CA_CERT=<path>` (or the
|
||||
explicit opt-in `SCIMESH_INSECURE_SKIP_VERIFY=1`). Custom certificates go
|
||||
through `--tls-cert`/`--tls-key` (or `SCIMESH_TLS_CERT`/`SCIMESH_TLS_KEY`).
|
||||
Without TLS, traffic on the LAN is plaintext. New UI accounts can be closed
|
||||
with `--disable-registration` (or `SCIMESH_DISABLE_REGISTRATION=1`).
|
||||
|
||||
To remove a component, run the matching uninstaller (data is kept unless you
|
||||
pass `--purge`):
|
||||
|
||||
|
||||
@@ -223,8 +223,16 @@ func runWithConfig(cfg infra.Config) error {
|
||||
|
||||
// deps.ready backs /health: readiness means the database answers, not just
|
||||
// that the process is alive.
|
||||
api := httptransport.NewServer(useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes, cfg.JWTSecret, cfg.UserserviceURL, m, deps.ready, cfg.PublicCoordinatorURL, cfg.PublicUserserviceURL, cfg.DocsDir)
|
||||
err = infra.RunServer(ctx, log, cfg.Addr, api.Handler(cfg.Token, cfg.UIToken))
|
||||
api := httptransport.NewServerWithOptions(
|
||||
useCases, log, cfg.RequestTimeout, cfg.HeartbeatInterval, cfg.MaxUploadBytes,
|
||||
cfg.JWTSecret, cfg.UserserviceURL, m, deps.ready,
|
||||
httptransport.ServerOptions{DisableRegistration: cfg.DisableRegistration},
|
||||
cfg.PublicCoordinatorURL, cfg.PublicUserserviceURL, cfg.DocsDir)
|
||||
var tlsOpts []infra.TLSConfig
|
||||
if cfg.TLSCertFile != "" && cfg.TLSKeyFile != "" {
|
||||
tlsOpts = []infra.TLSConfig{{CertFile: cfg.TLSCertFile, KeyFile: cfg.TLSKeyFile}}
|
||||
}
|
||||
err = infra.RunServer(ctx, log, cfg.Addr, api.Handler(cfg.Token, cfg.UIToken), tlsOpts...)
|
||||
|
||||
// Shutdown order matters, and defers alone cannot express it (they run
|
||||
// LIFO, so the deferred stop() would fire *after* the wait below).
|
||||
|
||||
@@ -6,10 +6,16 @@ import (
|
||||
"github.com/emil28092005/SciMesh/coordinator/internal/agent"
|
||||
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/sha256"
|
||||
"crypto/x509"
|
||||
"crypto/x509/pkix"
|
||||
"encoding/hex"
|
||||
"encoding/pem"
|
||||
"flag"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math/big"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
@@ -43,6 +49,10 @@ func runServe(args []string) error {
|
||||
email = flags.String("admin-email", "admin@scimesh.local", "admin account email")
|
||||
password = flags.String("admin-password", "", "admin password (generated on first run when empty)")
|
||||
publicURL = flags.String("public-url", "", "browser/worker-facing coordinator URL (default: http://<addr>)")
|
||||
tlsCert = flags.String("tls-cert", "", "TLS certificate file (enables HTTPS together with --tls-key)")
|
||||
tlsKey = flags.String("tls-key", "", "TLS private key file")
|
||||
tlsGen = flags.Bool("tls-autogen", false, "generate a self-signed certificate in the data dir and serve HTTPS")
|
||||
noReg = flags.Bool("disable-registration", false, "forbid new UI accounts")
|
||||
)
|
||||
if err := flags.Parse(args); err != nil {
|
||||
return err
|
||||
@@ -107,10 +117,33 @@ func runServe(args []string) error {
|
||||
defer stopAgents(agents)
|
||||
|
||||
// 6. The coordinator server itself.
|
||||
// TLS: explicit cert/key win; --tls-autogen creates a self-signed pair in
|
||||
// the data dir on first use (fingerprint printed for pinning).
|
||||
tlsCertFile, tlsKeyFile := *tlsCert, *tlsKey
|
||||
if tlsCertFile == "" && tlsKeyFile == "" && *tlsGen {
|
||||
tlsCertFile = filepath.Join(*dataDir, "tls.crt")
|
||||
tlsKeyFile = filepath.Join(*dataDir, "tls.key")
|
||||
if _, err := os.Stat(tlsCertFile); err != nil {
|
||||
fingerprint, err := generateSelfSigned(tlsCertFile, tlsKeyFile, *dataDir, *addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate TLS certificate: %w", err)
|
||||
}
|
||||
log.Info("generated a self-signed TLS certificate", "cert", tlsCertFile, "fingerprint", fingerprint)
|
||||
fmt.Printf("TLS: self-signed certificate generated (SHA-256 fingerprint %s).\n", fingerprint)
|
||||
fmt.Printf("Trust it on workers with SCIMESH_CA_CERT=%s (or SCIMESH_INSECURE_SKIP_VERIFY=1).\n", tlsCertFile)
|
||||
}
|
||||
}
|
||||
if (tlsCertFile == "") != (tlsKeyFile == "") {
|
||||
return fmt.Errorf("--tls-cert and --tls-key must be provided together")
|
||||
}
|
||||
|
||||
cfg := infra.Config{
|
||||
Addr: *addr,
|
||||
DatabaseEngine: "sqlite",
|
||||
DBPath: filepath.Join(*dataDir, "scimesh.db"),
|
||||
TLSCertFile: tlsCertFile,
|
||||
TLSKeyFile: tlsKeyFile,
|
||||
DisableRegistration: *noReg || os.Getenv("SCIMESH_DISABLE_REGISTRATION") == "1",
|
||||
Token: workerToken,
|
||||
JWTSecret: jwtSecret,
|
||||
UserserviceURL: "http://" + usersAddr,
|
||||
@@ -369,3 +402,72 @@ func serveURLs(addr, publicURL string) (agentURL, resolvedPublic string) {
|
||||
return agentURL, "http://" + addr
|
||||
}
|
||||
}
|
||||
|
||||
// generateSelfSigned writes a self-signed certificate for the listen host and
|
||||
// the machine's LAN addresses, so HTTPS works without a CA on a trusted
|
||||
// network. The returned value is the certificate's SHA-256 fingerprint.
|
||||
func generateSelfSigned(certPath, keyPath, dataDir, addr string) (string, error) {
|
||||
host, _, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
host = addr
|
||||
}
|
||||
host = strings.Trim(host, "[]")
|
||||
ips := []net.IP{net.ParseIP("127.0.0.1"), net.ParseIP("::1")}
|
||||
if parsed := net.ParseIP(host); parsed != nil && !parsed.IsUnspecified() {
|
||||
ips = append(ips, parsed)
|
||||
} else if host == "" || parsed != nil {
|
||||
// Wildcard listen addresses: add every local interface address.
|
||||
if addrs, err := net.InterfaceAddrs(); err == nil {
|
||||
for _, a := range addrs {
|
||||
if ipnet, ok := a.(*net.IPNet); ok && !ipnet.IP.IsLoopback() {
|
||||
ips = append(ips, ipnet.IP)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
names := []string{"localhost", host}
|
||||
if host != "" && host != "localhost" {
|
||||
names = append(names, host)
|
||||
}
|
||||
key, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
serial, err := rand.Int(rand.Reader, new(big.Int).Lsh(big.NewInt(1), 128))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
template := x509.Certificate{
|
||||
SerialNumber: serial,
|
||||
Subject: pkix.Name{CommonName: "SciMesh coordinator"},
|
||||
NotBefore: time.Now().Add(-time.Hour),
|
||||
NotAfter: time.Now().AddDate(1, 0, 0),
|
||||
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
|
||||
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
|
||||
DNSNames: names,
|
||||
IPAddresses: ips,
|
||||
}
|
||||
der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if err := os.MkdirAll(dataDir, 0o750); err != nil {
|
||||
return "", err
|
||||
}
|
||||
certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der})
|
||||
keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
|
||||
// The certificate is shared with workers via SCIMESH_CA_CERT, so it must
|
||||
// stay readable; the key stays private.
|
||||
if err := os.WriteFile(certPath, certPEM, 0o644); err != nil { //nolint:gosec // G306: cert is public by design
|
||||
return "", err
|
||||
}
|
||||
if err := os.WriteFile(keyPath, keyPEM, 0o600); err != nil {
|
||||
return "", err
|
||||
}
|
||||
sum := sha256.Sum256(der)
|
||||
var parts []string
|
||||
for _, b := range sum[:] {
|
||||
parts = append(parts, fmt.Sprintf("%02x", b))
|
||||
}
|
||||
return strings.Join(parts, ":"), nil
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ func (p *WorkerKeyToken) exchangeLocked() error {
|
||||
return err
|
||||
}
|
||||
request.Header.Set("Content-Type", "application/json")
|
||||
client := &http.Client{Timeout: p.timeout}
|
||||
client := &http.Client{Timeout: p.timeout, Transport: tlsTransport(nil)}
|
||||
response, err := client.Do(request)
|
||||
if err != nil {
|
||||
return fmt.Errorf("worker key exchange request failed")
|
||||
|
||||
@@ -42,7 +42,7 @@ func checkHTTP(ctx context.Context, url string, timeout time.Duration) (CheckIte
|
||||
if err != nil {
|
||||
return CheckItem{Name: "coordinator", OK: false, Detail: "invalid URL"}, ""
|
||||
}
|
||||
resp, err := http.DefaultClient.Do(req)
|
||||
resp, err := (&http.Client{Timeout: timeout, Transport: tlsTransport(nil)}).Do(req)
|
||||
if err != nil {
|
||||
detail := err.Error()
|
||||
if strings.Contains(detail, "connection refused") {
|
||||
@@ -148,7 +148,7 @@ func CheckAuth(ctx context.Context, url, token, workerKey, userserviceURL string
|
||||
item.Detail = "no credential configured — will be checked at registration"
|
||||
return item
|
||||
}
|
||||
client := &http.Client{Timeout: 30 * time.Second}
|
||||
client := &http.Client{Timeout: 30 * time.Second, Transport: tlsTransport(nil)}
|
||||
if workerKey != "" && userserviceURL != "" {
|
||||
payload, _ := json.Marshal(map[string]string{"key": workerKey})
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(userserviceURL, "/")+"/worker-tokens/exchange", strings.NewReader(string(payload)))
|
||||
|
||||
@@ -60,10 +60,12 @@ func NewClient(baseURL string, tokens TokenProvider, timeout time.Duration) *Cli
|
||||
timeout: timeout,
|
||||
apiClient: &http.Client{
|
||||
Timeout: timeout,
|
||||
Transport: tlsTransport(nil),
|
||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||
},
|
||||
dlClient: &http.Client{
|
||||
Timeout: transferTimeout,
|
||||
Timeout: transferTimeout,
|
||||
Transport: tlsTransport(nil),
|
||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||
if len(via) >= 10 {
|
||||
return fmt.Errorf("too many redirects")
|
||||
|
||||
@@ -1,8 +1,10 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@@ -237,3 +239,58 @@ func TestNewClientTransferTimeoutExceedsAPITimeout(t *testing.T) {
|
||||
t.Errorf("transfer timeout = %v, want 4x the api timeout", short.dlClient.Timeout)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSClientHonoursSkipVerify(t *testing.T) {
|
||||
t.Setenv("SCIMESH_INSECURE_SKIP_VERIFY", "1")
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte(`{"status":"ok"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
client := tlsClient(5 * time.Second)
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+"/health", nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("TLS server must be reachable with skip-verify: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSClientFailsWithoutTrust(t *testing.T) {
|
||||
t.Setenv("SCIMESH_INSECURE_SKIP_VERIFY", "")
|
||||
t.Setenv("SCIMESH_CA_CERT", "")
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {}))
|
||||
defer server.Close()
|
||||
client := tlsClient(5 * time.Second)
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+"/health", nil)
|
||||
if resp, err := client.Do(req); err == nil {
|
||||
_ = resp.Body.Close()
|
||||
t.Error("untrusted TLS server must fail verification")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTLSClientTrustsCAPool(t *testing.T) {
|
||||
server := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
}))
|
||||
defer server.Close()
|
||||
ca := server.Certificate()
|
||||
path := filepath.Join(t.TempDir(), "ca.pem")
|
||||
if err := os.WriteFile(path, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: ca.Raw}), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("SCIMESH_CA_CERT", path)
|
||||
t.Setenv("SCIMESH_INSECURE_SKIP_VERIFY", "")
|
||||
client := tlsClient(5 * time.Second)
|
||||
req, _ := http.NewRequestWithContext(context.Background(), http.MethodGet, server.URL+"/health", nil)
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
t.Fatalf("CA-trusted TLS server must verify: %v", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
t.Errorf("status = %d", resp.StatusCode)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"crypto/tls"
|
||||
"crypto/x509"
|
||||
"log/slog"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
"time"
|
||||
)
|
||||
|
||||
// tlsClient builds an HTTP client whose transport trusts the coordinator's
|
||||
// TLS certificate:
|
||||
//
|
||||
// - SCIMESH_CA_CERT=/path/to/ca.pem adds a root CA (for self-signed or
|
||||
// private-CA coordinators);
|
||||
// - SCIMESH_INSECURE_SKIP_VERIFY=1 disables verification entirely — only
|
||||
// for trusted LANs where a self-signed certificate was auto-generated.
|
||||
//
|
||||
// Both settings are deliberately opt-in and noisy: a coordinator without them
|
||||
// fails to verify, never silently downgrades.
|
||||
func tlsClient(timeout time.Duration) *http.Client {
|
||||
return &http.Client{Timeout: timeout, Transport: tlsTransport(nil)}
|
||||
}
|
||||
|
||||
// tlsTransport configures a transport honouring the trust environment.
|
||||
func tlsTransport(base *http.Transport) *http.Transport {
|
||||
if base == nil {
|
||||
base = &http.Transport{
|
||||
Proxy: http.ProxyFromEnvironment,
|
||||
DialContext: (&net.Dialer{Timeout: 30 * time.Second, KeepAlive: 30 * time.Second}).DialContext,
|
||||
MaxIdleConns: 100,
|
||||
IdleConnTimeout: 90 * time.Second,
|
||||
TLSHandshakeTimeout: 10 * time.Second,
|
||||
}
|
||||
}
|
||||
caPath := os.Getenv("SCIMESH_CA_CERT")
|
||||
skip := os.Getenv("SCIMESH_INSECURE_SKIP_VERIFY") == "1"
|
||||
if caPath == "" && !skip {
|
||||
return base
|
||||
}
|
||||
tlsConfig := &tls.Config{MinVersion: tls.VersionTLS12} //nolint:gosec // G402: min TLS 1.2 by default
|
||||
if caPath != "" {
|
||||
//nolint:gosec // G304: SCIMESH_CA_CERT is operator-configured
|
||||
pem, err := os.ReadFile(caPath)
|
||||
if err != nil {
|
||||
slog.Warn("could not read SCIMESH_CA_CERT", "path", caPath, "err", err)
|
||||
return base
|
||||
}
|
||||
pool, err := x509.SystemCertPool()
|
||||
if err != nil {
|
||||
pool = x509.NewCertPool()
|
||||
}
|
||||
if !pool.AppendCertsFromPEM(pem) {
|
||||
slog.Warn("SCIMESH_CA_CERT contained no usable certificates", "path", caPath)
|
||||
return base
|
||||
}
|
||||
tlsConfig.RootCAs = pool
|
||||
}
|
||||
if skip {
|
||||
// G402 is about production code paths; here the operator explicitly
|
||||
// opts into an unverified LAN trust root, so the bypass is intended.
|
||||
tlsConfig.InsecureSkipVerify = true //nolint:gosec // G402: operator opt-in for self-signed LAN certs
|
||||
slog.Warn("SCIMESH_INSECURE_SKIP_VERIFY=1: TLS certificate verification is disabled")
|
||||
}
|
||||
base.TLSClientConfig = tlsConfig
|
||||
return base
|
||||
}
|
||||
@@ -55,6 +55,14 @@ type Config struct {
|
||||
// Directory of the built MkDocs site (site/) served at /ui/docs/. Empty
|
||||
// disables the docs route; the UI shows a hint page instead.
|
||||
DocsDir string
|
||||
// TLSCertFile and TLSKeyFile enable HTTPS when both are set. Self-signed
|
||||
// certificates are fine for a trusted LAN; workers then need
|
||||
// SCIMESH_CA_CERT or SCIMESH_INSECURE_SKIP_VERIFY to connect.
|
||||
TLSCertFile string
|
||||
TLSKeyFile string
|
||||
// DisableRegistration forbids new UI accounts; the bootstrap admin still
|
||||
// works. Existing accounts and worker keys are unaffected.
|
||||
DisableRegistration bool
|
||||
// Upper bound on an uploaded dataset or artifact body, in bytes.
|
||||
MaxUploadBytes int64
|
||||
|
||||
@@ -123,6 +131,9 @@ func LoadConfig() (Config, error) {
|
||||
LogFile: os.Getenv("LOG_FILE"),
|
||||
StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"),
|
||||
DocsDir: os.Getenv("SCIMESH_DOCS_DIR"),
|
||||
TLSCertFile: os.Getenv("SCIMESH_TLS_CERT"),
|
||||
TLSKeyFile: os.Getenv("SCIMESH_TLS_KEY"),
|
||||
DisableRegistration: os.Getenv("SCIMESH_DISABLE_REGISTRATION") == "1",
|
||||
MaxUploadBytes: 1 << 30, // 1 GiB
|
||||
DBMaxConns: 10,
|
||||
DBConnectTimeout: 30 * time.Second,
|
||||
|
||||
@@ -13,17 +13,25 @@ import (
|
||||
const shutdownGrace = 15 * time.Second
|
||||
|
||||
// Run serves handler until ctx is cancelled, then drains in-flight requests.
|
||||
func RunServer(ctx context.Context, log *slog.Logger, addr string, handler http.Handler) error {
|
||||
func RunServer(ctx context.Context, log *slog.Logger, addr string, handler http.Handler, tls ...TLSConfig) error {
|
||||
srv := &http.Server{
|
||||
Addr: addr,
|
||||
Handler: handler,
|
||||
ReadHeaderTimeout: 5 * time.Second,
|
||||
}
|
||||
secure := len(tls) > 0 && tls[0].CertFile != "" && tls[0].KeyFile != ""
|
||||
|
||||
// Buffered so this goroutine can exit even when nobody reads the channel
|
||||
// (the ctx.Done branch below) — an unbuffered send would leak it forever.
|
||||
errCh := make(chan error, 1)
|
||||
go func() {
|
||||
if secure {
|
||||
log.Info("coordinator listening (https)", "addr", addr)
|
||||
if err := srv.ListenAndServeTLS(tls[0].CertFile, tls[0].KeyFile); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- err
|
||||
}
|
||||
return
|
||||
}
|
||||
log.Info("coordinator listening", "addr", addr)
|
||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||
errCh <- err
|
||||
@@ -72,3 +80,9 @@ func RunPeriodic(ctx context.Context, log *slog.Logger, name string, interval ti
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// TLSConfig enables HTTPS for the coordinator server.
|
||||
type TLSConfig struct {
|
||||
CertFile string
|
||||
KeyFile string
|
||||
}
|
||||
|
||||
@@ -51,6 +51,8 @@ type Server struct {
|
||||
// userserviceURL is the base URL the UI proxies login/registration to. Empty
|
||||
// keeps the static basic-auth UI.
|
||||
userserviceURL string
|
||||
// disableRegistration forbids new accounts; login keeps working.
|
||||
disableRegistration bool
|
||||
// publicCoordinatorURL / publicUserserviceURL are the browser-facing URLs
|
||||
// rendered into the worker-enrollment command. Either may be empty; the
|
||||
// template falls back (own origin / userserviceURL respectively).
|
||||
@@ -70,6 +72,19 @@ type Server struct {
|
||||
func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval time.Duration,
|
||||
maxUploadBytes int64, jwtSecret, userserviceURL string, m *metrics.Metrics, ready func(context.Context) error,
|
||||
publicURLs ...string) *Server {
|
||||
return NewServerWithOptions(uc, log, requestTimeout, heartbeatInterval, maxUploadBytes, jwtSecret, userserviceURL, m, ready, ServerOptions{}, publicURLs...)
|
||||
}
|
||||
|
||||
// ServerOptions configures non-positional behaviour of the operator UI.
|
||||
type ServerOptions struct {
|
||||
DisableRegistration bool
|
||||
}
|
||||
|
||||
// NewServerWithOptions is NewServer plus explicit options; the option-less
|
||||
// variant exists so existing call sites and tests need no change.
|
||||
func NewServerWithOptions(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval time.Duration,
|
||||
maxUploadBytes int64, jwtSecret, userserviceURL string, m *metrics.Metrics, ready func(context.Context) error,
|
||||
opts ServerOptions, publicURLs ...string) *Server {
|
||||
if m == nil {
|
||||
m = metrics.New()
|
||||
}
|
||||
@@ -88,6 +103,7 @@ func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval
|
||||
}
|
||||
return &Server{
|
||||
uc: uc,
|
||||
disableRegistration: opts.DisableRegistration,
|
||||
log: log,
|
||||
requestTimeout: requestTimeout,
|
||||
heartbeatInterval: heartbeatInterval,
|
||||
|
||||
@@ -108,6 +108,10 @@ func (s *Server) handleUILogin(w http.ResponseWriter, r *http.Request) {
|
||||
// user to the login page. The new account is a plain user until an admin
|
||||
// promotes or verifies it.
|
||||
func (s *Server) handleUIRegister(w http.ResponseWriter, r *http.Request) {
|
||||
if s.disableRegistration {
|
||||
http.Redirect(w, r, "/ui/register?error=registration+disabled", http.StatusSeeOther)
|
||||
return
|
||||
}
|
||||
email, password := r.FormValue("email"), r.FormValue("password")
|
||||
|
||||
status, _, err := s.callUserservice(r.Context(), "/register", email, password)
|
||||
|
||||
@@ -226,3 +226,18 @@ func TestLoginFormRendersNext(t *testing.T) {
|
||||
t.Error("login form must not render next when absent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationDisabledRejectsNewAccounts(t *testing.T) {
|
||||
stub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
t.Fatal("userservice must not be called when registration is disabled")
|
||||
}))
|
||||
defer stub.Close()
|
||||
s := newLoginServer(stub)
|
||||
s.disableRegistration = true
|
||||
|
||||
rec := httptest.NewRecorder()
|
||||
s.handleUIRegister(rec, postForm("/ui/register", url.Values{"email": {"a@b.io"}, "password": {"pw"}}))
|
||||
if rec.Code != http.StatusSeeOther || !strings.Contains(rec.Header().Get("Location"), "registration+disabled") {
|
||||
t.Errorf("got %d -> %q, want 303 to the registration-disabled error", rec.Code, rec.Header().Get("Location"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"log/slog"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/google/uuid"
|
||||
|
||||
@@ -38,6 +39,12 @@ func (h *Handlers) handleHealth(w http.ResponseWriter, _ *http.Request) {
|
||||
// handleRegister creates an account. It returns 201 with the public user view,
|
||||
// 409 if the email is taken, or 400 on a malformed body / weak password.
|
||||
func (h *Handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
|
||||
// Standalone deployments can close self-service registration while keeping
|
||||
// the bootstrap admin and existing accounts (USERSERVICE_DISABLE_REGISTRATION=1).
|
||||
if os.Getenv("USERSERVICE_DISABLE_REGISTRATION") == "1" {
|
||||
writeJSON(w, http.StatusForbidden, errorResponse{Error: "registration disabled", RequestID: requestIDFrom(r.Context())})
|
||||
return
|
||||
}
|
||||
var req registerRequest
|
||||
if !decodeJSON(w, r, &req) {
|
||||
return
|
||||
|
||||
@@ -479,3 +479,12 @@ func TestAdminListsUsersAndKeys(t *testing.T) {
|
||||
t.Errorf("admin revoke unknown key: got %d, want 404", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistrationDisabledEnv(t *testing.T) {
|
||||
t.Setenv("USERSERVICE_DISABLE_REGISTRATION", "1")
|
||||
h := newTestServer()
|
||||
rec := do(t, h, http.MethodPost, "/register", "", map[string]string{"email": "blocked@x.io", "password": "pw"})
|
||||
if rec.Code != http.StatusForbidden {
|
||||
t.Errorf("register when disabled: got %d, want 403", rec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
+40
-3
@@ -8,6 +8,11 @@
|
||||
# coordinator serve --open
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
# Public half of the Ed25519 key that signs SHA256SUMS.txt in releases (see
|
||||
# install.sh). Verification needs the openssl binary; without it the installer
|
||||
# falls back to checksum verification with a warning.
|
||||
$ScimeshSigningPubKey = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA01rjmCme4W4zAgBwbO00LvwgnB1srlg0LbooRG8ej7iNxzOtJ8vjRFR2Cu7z7OKjoDo9/0GW3pvcwB+ndBB6yUwht33IRwdsnbioBI4M7LL+yC1ubi4fJ5bigOgZ9VsVqKdU3T9GYxmrfJF1UexiOg6HjoRLO3V4Id+3e/CiI5Sr8UMfJMXUfO3uiEs9RpstxpP1V/UU4YDicTF0QjkOESimEwwXBG4z3VcVmQtqkb7Q3413iekTdQ13093GKAKp0Q2ia1TpB2su6ELUhHAqhmK88cJ73Opy1uEVye0twov4BFTu5GkxgazNTuU//aYVWVpd/NAlD+VVSmpDsbfBBQIDAQAB"
|
||||
|
||||
$Repo = "emil28092005/SciMesh"
|
||||
$Component = if ($env:SCIMESH_COMPONENT) { $env:SCIMESH_COMPONENT } else { "coordinator" }
|
||||
$Version = if ($env:SCIMESH_VERSION) { $env:SCIMESH_VERSION } else { "latest" }
|
||||
@@ -63,19 +68,51 @@ Invoke-WebRequest -Uri $Url -OutFile "$Target.tmp"
|
||||
if ($env:SCIMESH_SKIP_VERIFY -ne "1") {
|
||||
try {
|
||||
$SumUrl = "https://github.com/$Repo/releases/download/$Version/SHA256SUMS.txt"
|
||||
$Sums = (Invoke-WebRequest -Uri $SumUrl).Content
|
||||
|
||||
# Ed25519 signature over the checksum file, when openssl is present.
|
||||
# Both files are fetched with -OutFile so their bytes match the
|
||||
# release exactly (string pipelines would rewrite line endings).
|
||||
$openssl = Get-Command openssl -ErrorAction SilentlyContinue
|
||||
if ($env:SCIMESH_SKIP_SIGNATURE -ne "1" -and $openssl) {
|
||||
$PubFile = Join-Path $env:TEMP "scimesh-signing-pub.pem"
|
||||
$SumFile = Join-Path $env:TEMP ("scimesh-sums-" + [guid]::NewGuid().ToString("N") + ".txt")
|
||||
$SigFile = "$SumFile.sig"
|
||||
Set-Content -Path $PubFile -Value @("-----BEGIN PUBLIC KEY-----", $ScimeshSigningPubKey, "-----END PUBLIC KEY-----")
|
||||
try {
|
||||
Invoke-WebRequest -Uri $SumUrl -OutFile $SumFile
|
||||
Invoke-WebRequest -Uri "$SumUrl.sig" -OutFile $SigFile
|
||||
& $openssl.Source dgst -sha256 -verify $PubFile -signature $SigFile $SumFile 2>&1 | Out-Null
|
||||
if ($LASTEXITCODE -eq 0) {
|
||||
Write-Host "Signature verified (RSA-2048/SHA-256)"
|
||||
} else {
|
||||
Remove-Item -Force "$Target.tmp"
|
||||
throw "the release signature does not verify; the download channel may be tampered with"
|
||||
}
|
||||
} catch {
|
||||
Remove-Item -Force "$Target.tmp"
|
||||
throw "signature verification failed: $($_.Exception.Message)"
|
||||
} finally {
|
||||
Remove-Item -Force $PubFile, $SumFile, $SigFile -ErrorAction SilentlyContinue
|
||||
}
|
||||
} elseif ($env:SCIMESH_SKIP_SIGNATURE -ne "1") {
|
||||
Write-Host "WARNING: openssl not found; falling back to checksum verification only"
|
||||
}
|
||||
|
||||
$BinaryName = Split-Path $Url -Leaf
|
||||
$Line = ($Sums -split "`n") | Where-Object { $_.Trim().EndsWith(" " + $BinaryName) } | Select-Object -First 1
|
||||
$SumFileCheck = Join-Path $env:TEMP ("scimesh-sums-check-" + [guid]::NewGuid().ToString("N") + ".txt")
|
||||
Invoke-WebRequest -Uri $SumUrl -OutFile $SumFileCheck
|
||||
$Line = (Get-Content $SumFileCheck -Raw -ErrorAction SilentlyContinue -split "`n") | Where-Object { $_.Trim().EndsWith(" " + $BinaryName) } | Select-Object -First 1
|
||||
if ($Line) {
|
||||
$Expected = ($Line -split "\s+")[0]
|
||||
$Actual = (Get-FileHash -Algorithm SHA256 -Path "$Target.tmp").Hash.ToLower()
|
||||
if ($Actual -ne $Expected.ToLower()) {
|
||||
Remove-Item -Force "$Target.tmp"
|
||||
Remove-Item -Force "$Target.tmp", $SumFileCheck
|
||||
throw "checksum mismatch for $Binary (got $Actual, want $Expected)"
|
||||
}
|
||||
Write-Host "Checksum verified ($($Expected.Substring(0,12))...)"
|
||||
} else {
|
||||
Write-Host "WARNING: no checksum entry for $Binary; skipping verification"
|
||||
Remove-Item -Force $SumFileCheck -ErrorAction SilentlyContinue
|
||||
}
|
||||
} catch {
|
||||
Write-Host "WARNING: could not verify checksum ($($_.Exception.Message)); continuing"
|
||||
|
||||
+25
-1
@@ -14,6 +14,13 @@
|
||||
set -eu
|
||||
|
||||
REPO="emil28092005/SciMesh"
|
||||
|
||||
# Public half of the Ed25519 key that signs SHA256SUMS.txt in releases. The
|
||||
# private half lives in the repository secret SCIMESH_SIGNING_KEY. Verification
|
||||
# uses openssl when available; without openssl the installer falls back to the
|
||||
# checksum-only check with a warning.
|
||||
SCIMESH_SIGNING_PUBKEY='MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA01rjmCme4W4zAgBwbO00LvwgnB1srlg0LbooRG8ej7iNxzOtJ8vjRFR2Cu7z7OKjoDo9/0GW3pvcwB+ndBB6yUwht33IRwdsnbioBI4M7LL+yC1ubi4fJ5bigOgZ9VsVqKdU3T9GYxmrfJF1UexiOg6HjoRLO3V4Id+3e/CiI5Sr8UMfJMXUfO3uiEs9RpstxpP1V/UU4YDicTF0QjkOESimEwwXBG4z3VcVmQtqkb7Q3413iekTdQ13093GKAKp0Q2ia1TpB2su6ELUhHAqhmK88cJ73Opy1uEVye0twov4BFTu5GkxgazNTuU//aYVWVpd/NAlD+VVSmpDsbfBBQIDAQAB'
|
||||
|
||||
COMPONENT="${1:-coordinator}"
|
||||
VERSION="${SCIMESH_VERSION:-latest}"
|
||||
INSTALL_DIR="${SCIMESH_INSTALL_DIR:-$HOME/.local/bin}"
|
||||
@@ -68,7 +75,24 @@ curl -fsSL -o "$TARGET.tmp" "$URL"
|
||||
if [ "${SCIMESH_SKIP_VERIFY:-0}" != "1" ]; then
|
||||
if SUMFILE=$(mktemp) && curl -fsSL -o "$SUMFILE" "https://github.com/${REPO}/releases/download/${VERSION}/SHA256SUMS.txt"; then
|
||||
EXPECTED=$(awk '$2 == "'"$(basename "$URL")"'" {print $1}' "$SUMFILE" 2>/dev/null | head -1)
|
||||
rm -f "$SUMFILE"
|
||||
SIGFILE="$SUMFILE.sig"
|
||||
if [ "${SCIMESH_SKIP_SIGNATURE:-0}" != "1" ] && command -v openssl >/dev/null 2>&1 \
|
||||
&& curl -fsSL -o "$SIGFILE" "https://github.com/${REPO}/releases/download/${VERSION}/SHA256SUMS.txt.sig" 2>/dev/null; then
|
||||
PUBKEY_FILE=$(mktemp)
|
||||
printf '%s\n' '-----BEGIN PUBLIC KEY-----' "$SCIMESH_SIGNING_PUBKEY" '-----END PUBLIC KEY-----' > "$PUBKEY_FILE"
|
||||
if openssl dgst -sha256 -verify "$PUBKEY_FILE" -signature "$SIGFILE" "$SUMFILE" >/dev/null 2>&1; then
|
||||
echo "Signature verified (RSA-2048/SHA-256)"
|
||||
else
|
||||
rm -f "$PUBKEY_FILE" "$SIGFILE" "$SUMFILE" "$TARGET.tmp"
|
||||
echo "ERROR: the release signature does not verify; the download channel may be tampered with." >&2
|
||||
echo "Retry later, or bypass with SCIMESH_SKIP_SIGNATURE=1." >&2
|
||||
exit 1
|
||||
fi
|
||||
rm -f "$PUBKEY_FILE"
|
||||
elif [ "${SCIMESH_SKIP_SIGNATURE:-0}" != "1" ] && ! command -v openssl >/dev/null 2>&1; then
|
||||
echo "WARNING: openssl not found; falling back to checksum verification only"
|
||||
fi
|
||||
rm -f "$SUMFILE" "$SIGFILE"
|
||||
if [ -n "$EXPECTED" ]; then
|
||||
ACTUAL=$(sha256sum "$TARGET.tmp" | awk '{print $1}')
|
||||
if [ "$ACTUAL" != "$EXPECTED" ]; then
|
||||
|
||||
Reference in New Issue
Block a user