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
|
working-directory: artifacts
|
||||||
run: sha256sum * > SHA256SUMS.txt
|
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
|
- uses: softprops/action-gh-release@v2
|
||||||
with:
|
with:
|
||||||
files: |
|
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
|
Set `SCIMESH_AUTO_START=0` to install without starting anything. The old demo
|
||||||
control room was removed: `/ui` is the admin console.
|
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
|
To remove a component, run the matching uninstaller (data is kept unless you
|
||||||
pass `--purge`):
|
pass `--purge`):
|
||||||
|
|
||||||
|
|||||||
@@ -223,8 +223,16 @@ func runWithConfig(cfg infra.Config) error {
|
|||||||
|
|
||||||
// deps.ready backs /health: readiness means the database answers, not just
|
// deps.ready backs /health: readiness means the database answers, not just
|
||||||
// that the process is alive.
|
// 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)
|
api := httptransport.NewServerWithOptions(
|
||||||
err = infra.RunServer(ctx, log, cfg.Addr, api.Handler(cfg.Token, cfg.UIToken))
|
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
|
// Shutdown order matters, and defers alone cannot express it (they run
|
||||||
// LIFO, so the deferred stop() would fire *after* the wait below).
|
// LIFO, so the deferred stop() would fire *after* the wait below).
|
||||||
|
|||||||
@@ -6,10 +6,16 @@ import (
|
|||||||
"github.com/emil28092005/SciMesh/coordinator/internal/agent"
|
"github.com/emil28092005/SciMesh/coordinator/internal/agent"
|
||||||
|
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
|
"crypto/rsa"
|
||||||
|
"crypto/sha256"
|
||||||
|
"crypto/x509"
|
||||||
|
"crypto/x509/pkix"
|
||||||
"encoding/hex"
|
"encoding/hex"
|
||||||
|
"encoding/pem"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"math/big"
|
||||||
"net"
|
"net"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
@@ -43,6 +49,10 @@ func runServe(args []string) error {
|
|||||||
email = flags.String("admin-email", "admin@scimesh.local", "admin account email")
|
email = flags.String("admin-email", "admin@scimesh.local", "admin account email")
|
||||||
password = flags.String("admin-password", "", "admin password (generated on first run when empty)")
|
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>)")
|
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 {
|
if err := flags.Parse(args); err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -107,10 +117,33 @@ func runServe(args []string) error {
|
|||||||
defer stopAgents(agents)
|
defer stopAgents(agents)
|
||||||
|
|
||||||
// 6. The coordinator server itself.
|
// 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{
|
cfg := infra.Config{
|
||||||
Addr: *addr,
|
Addr: *addr,
|
||||||
DatabaseEngine: "sqlite",
|
DatabaseEngine: "sqlite",
|
||||||
DBPath: filepath.Join(*dataDir, "scimesh.db"),
|
DBPath: filepath.Join(*dataDir, "scimesh.db"),
|
||||||
|
TLSCertFile: tlsCertFile,
|
||||||
|
TLSKeyFile: tlsKeyFile,
|
||||||
|
DisableRegistration: *noReg || os.Getenv("SCIMESH_DISABLE_REGISTRATION") == "1",
|
||||||
Token: workerToken,
|
Token: workerToken,
|
||||||
JWTSecret: jwtSecret,
|
JWTSecret: jwtSecret,
|
||||||
UserserviceURL: "http://" + usersAddr,
|
UserserviceURL: "http://" + usersAddr,
|
||||||
@@ -369,3 +402,72 @@ func serveURLs(addr, publicURL string) (agentURL, resolvedPublic string) {
|
|||||||
return agentURL, "http://" + addr
|
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
|
return err
|
||||||
}
|
}
|
||||||
request.Header.Set("Content-Type", "application/json")
|
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)
|
response, err := client.Do(request)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("worker key exchange request failed")
|
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 {
|
if err != nil {
|
||||||
return CheckItem{Name: "coordinator", OK: false, Detail: "invalid URL"}, ""
|
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 {
|
if err != nil {
|
||||||
detail := err.Error()
|
detail := err.Error()
|
||||||
if strings.Contains(detail, "connection refused") {
|
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"
|
item.Detail = "no credential configured — will be checked at registration"
|
||||||
return item
|
return item
|
||||||
}
|
}
|
||||||
client := &http.Client{Timeout: 30 * time.Second}
|
client := &http.Client{Timeout: 30 * time.Second, Transport: tlsTransport(nil)}
|
||||||
if workerKey != "" && userserviceURL != "" {
|
if workerKey != "" && userserviceURL != "" {
|
||||||
payload, _ := json.Marshal(map[string]string{"key": workerKey})
|
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)))
|
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,
|
timeout: timeout,
|
||||||
apiClient: &http.Client{
|
apiClient: &http.Client{
|
||||||
Timeout: timeout,
|
Timeout: timeout,
|
||||||
|
Transport: tlsTransport(nil),
|
||||||
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
|
||||||
},
|
},
|
||||||
dlClient: &http.Client{
|
dlClient: &http.Client{
|
||||||
Timeout: transferTimeout,
|
Timeout: transferTimeout,
|
||||||
|
Transport: tlsTransport(nil),
|
||||||
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
CheckRedirect: func(req *http.Request, via []*http.Request) error {
|
||||||
if len(via) >= 10 {
|
if len(via) >= 10 {
|
||||||
return fmt.Errorf("too many redirects")
|
return fmt.Errorf("too many redirects")
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"crypto/sha256"
|
"crypto/sha256"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"encoding/pem"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
@@ -237,3 +239,58 @@ func TestNewClientTransferTimeoutExceedsAPITimeout(t *testing.T) {
|
|||||||
t.Errorf("transfer timeout = %v, want 4x the api timeout", short.dlClient.Timeout)
|
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
|
// Directory of the built MkDocs site (site/) served at /ui/docs/. Empty
|
||||||
// disables the docs route; the UI shows a hint page instead.
|
// disables the docs route; the UI shows a hint page instead.
|
||||||
DocsDir string
|
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.
|
// Upper bound on an uploaded dataset or artifact body, in bytes.
|
||||||
MaxUploadBytes int64
|
MaxUploadBytes int64
|
||||||
|
|
||||||
@@ -123,6 +131,9 @@ func LoadConfig() (Config, error) {
|
|||||||
LogFile: os.Getenv("LOG_FILE"),
|
LogFile: os.Getenv("LOG_FILE"),
|
||||||
StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"),
|
StorageDir: getEnv("COORDINATOR_STORAGE_DIR", "./data"),
|
||||||
DocsDir: os.Getenv("SCIMESH_DOCS_DIR"),
|
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
|
MaxUploadBytes: 1 << 30, // 1 GiB
|
||||||
DBMaxConns: 10,
|
DBMaxConns: 10,
|
||||||
DBConnectTimeout: 30 * time.Second,
|
DBConnectTimeout: 30 * time.Second,
|
||||||
|
|||||||
@@ -13,17 +13,25 @@ import (
|
|||||||
const shutdownGrace = 15 * time.Second
|
const shutdownGrace = 15 * time.Second
|
||||||
|
|
||||||
// Run serves handler until ctx is cancelled, then drains in-flight requests.
|
// 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{
|
srv := &http.Server{
|
||||||
Addr: addr,
|
Addr: addr,
|
||||||
Handler: handler,
|
Handler: handler,
|
||||||
ReadHeaderTimeout: 5 * time.Second,
|
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
|
// Buffered so this goroutine can exit even when nobody reads the channel
|
||||||
// (the ctx.Done branch below) — an unbuffered send would leak it forever.
|
// (the ctx.Done branch below) — an unbuffered send would leak it forever.
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
go func() {
|
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)
|
log.Info("coordinator listening", "addr", addr)
|
||||||
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
|
||||||
errCh <- err
|
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
|
// userserviceURL is the base URL the UI proxies login/registration to. Empty
|
||||||
// keeps the static basic-auth UI.
|
// keeps the static basic-auth UI.
|
||||||
userserviceURL string
|
userserviceURL string
|
||||||
|
// disableRegistration forbids new accounts; login keeps working.
|
||||||
|
disableRegistration bool
|
||||||
// publicCoordinatorURL / publicUserserviceURL are the browser-facing URLs
|
// publicCoordinatorURL / publicUserserviceURL are the browser-facing URLs
|
||||||
// rendered into the worker-enrollment command. Either may be empty; the
|
// rendered into the worker-enrollment command. Either may be empty; the
|
||||||
// template falls back (own origin / userserviceURL respectively).
|
// 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,
|
func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval time.Duration,
|
||||||
maxUploadBytes int64, jwtSecret, userserviceURL string, m *metrics.Metrics, ready func(context.Context) error,
|
maxUploadBytes int64, jwtSecret, userserviceURL string, m *metrics.Metrics, ready func(context.Context) error,
|
||||||
publicURLs ...string) *Server {
|
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 {
|
if m == nil {
|
||||||
m = metrics.New()
|
m = metrics.New()
|
||||||
}
|
}
|
||||||
@@ -88,6 +103,7 @@ func NewServer(uc UseCases, log *slog.Logger, requestTimeout, heartbeatInterval
|
|||||||
}
|
}
|
||||||
return &Server{
|
return &Server{
|
||||||
uc: uc,
|
uc: uc,
|
||||||
|
disableRegistration: opts.DisableRegistration,
|
||||||
log: log,
|
log: log,
|
||||||
requestTimeout: requestTimeout,
|
requestTimeout: requestTimeout,
|
||||||
heartbeatInterval: heartbeatInterval,
|
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
|
// user to the login page. The new account is a plain user until an admin
|
||||||
// promotes or verifies it.
|
// promotes or verifies it.
|
||||||
func (s *Server) handleUIRegister(w http.ResponseWriter, r *http.Request) {
|
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")
|
email, password := r.FormValue("email"), r.FormValue("password")
|
||||||
|
|
||||||
status, _, err := s.callUserservice(r.Context(), "/register", email, 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")
|
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"
|
"errors"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
"github.com/google/uuid"
|
"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,
|
// 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.
|
// 409 if the email is taken, or 400 on a malformed body / weak password.
|
||||||
func (h *Handlers) handleRegister(w http.ResponseWriter, r *http.Request) {
|
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
|
var req registerRequest
|
||||||
if !decodeJSON(w, r, &req) {
|
if !decodeJSON(w, r, &req) {
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -479,3 +479,12 @@ func TestAdminListsUsersAndKeys(t *testing.T) {
|
|||||||
t.Errorf("admin revoke unknown key: got %d, want 404", rec.Code)
|
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
|
# coordinator serve --open
|
||||||
$ErrorActionPreference = "Stop"
|
$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"
|
$Repo = "emil28092005/SciMesh"
|
||||||
$Component = if ($env:SCIMESH_COMPONENT) { $env:SCIMESH_COMPONENT } else { "coordinator" }
|
$Component = if ($env:SCIMESH_COMPONENT) { $env:SCIMESH_COMPONENT } else { "coordinator" }
|
||||||
$Version = if ($env:SCIMESH_VERSION) { $env:SCIMESH_VERSION } else { "latest" }
|
$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") {
|
if ($env:SCIMESH_SKIP_VERIFY -ne "1") {
|
||||||
try {
|
try {
|
||||||
$SumUrl = "https://github.com/$Repo/releases/download/$Version/SHA256SUMS.txt"
|
$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
|
$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) {
|
if ($Line) {
|
||||||
$Expected = ($Line -split "\s+")[0]
|
$Expected = ($Line -split "\s+")[0]
|
||||||
$Actual = (Get-FileHash -Algorithm SHA256 -Path "$Target.tmp").Hash.ToLower()
|
$Actual = (Get-FileHash -Algorithm SHA256 -Path "$Target.tmp").Hash.ToLower()
|
||||||
if ($Actual -ne $Expected.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)"
|
throw "checksum mismatch for $Binary (got $Actual, want $Expected)"
|
||||||
}
|
}
|
||||||
Write-Host "Checksum verified ($($Expected.Substring(0,12))...)"
|
Write-Host "Checksum verified ($($Expected.Substring(0,12))...)"
|
||||||
} else {
|
} else {
|
||||||
Write-Host "WARNING: no checksum entry for $Binary; skipping verification"
|
Write-Host "WARNING: no checksum entry for $Binary; skipping verification"
|
||||||
|
Remove-Item -Force $SumFileCheck -ErrorAction SilentlyContinue
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
Write-Host "WARNING: could not verify checksum ($($_.Exception.Message)); continuing"
|
Write-Host "WARNING: could not verify checksum ($($_.Exception.Message)); continuing"
|
||||||
|
|||||||
+25
-1
@@ -14,6 +14,13 @@
|
|||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
REPO="emil28092005/SciMesh"
|
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}"
|
COMPONENT="${1:-coordinator}"
|
||||||
VERSION="${SCIMESH_VERSION:-latest}"
|
VERSION="${SCIMESH_VERSION:-latest}"
|
||||||
INSTALL_DIR="${SCIMESH_INSTALL_DIR:-$HOME/.local/bin}"
|
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 [ "${SCIMESH_SKIP_VERIFY:-0}" != "1" ]; then
|
||||||
if SUMFILE=$(mktemp) && curl -fsSL -o "$SUMFILE" "https://github.com/${REPO}/releases/download/${VERSION}/SHA256SUMS.txt"; 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)
|
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
|
if [ -n "$EXPECTED" ]; then
|
||||||
ACTUAL=$(sha256sum "$TARGET.tmp" | awk '{print $1}')
|
ACTUAL=$(sha256sum "$TARGET.tmp" | awk '{print $1}')
|
||||||
if [ "$ACTUAL" != "$EXPECTED" ]; then
|
if [ "$ACTUAL" != "$EXPECTED" ]; then
|
||||||
|
|||||||
Reference in New Issue
Block a user