Compare commits

..
5 Commits
Author SHA1 Message Date
Cédric Verstraeten 949762c91b Merge pull request #340 from kerberos-io/fix/hub-failed-error-messages
fix/hub-failed-error-messages
2026-10-10 18:47:05 +02:00
Cédric Verstraeten b805b84d0a Align development API port to 8082
Use port 8082 for UI development endpoints, VS Code debugging, and devcontainer forwarding. Allow overriding the UI API port with REACT_APP_AGENT_API_PORT and document the matching backend setup.
2026-10-10 18:18:35 +02:00
Cédric Verstraeten a57b6310b2 Restrict Hub verification to saved endpoints
Require Hub and persistence verification URIs to match the saved configuration and reject redirects to prevent credential leakage. Add regression tests for unsaved URIs and cross-host redirects.
2026-10-10 18:09:53 +02:00
Cédric Verstraeten 2ec6faffe1 Update cloud_test.go 2026-10-10 18:01:17 +02:00
Cédric Verstraeten d40c544584 Verify Hub persistence with HEAD requests
Check upload authorization without uploading a test video. Guard against missing S3 configuration, strip credentials on cross-host redirects, and clarify rejection errors. Add coverage for HEAD requests and status-specific messages.
2026-10-10 17:52:52 +02:00
7 changed files with 273 additions and 27 deletions
+1 -1
View File
@@ -18,7 +18,7 @@
},
"forwardPorts": [
3000,
8080
8082
],
"postCreateCommand": "cd ui && yarn install && yarn build && cd ../machinery && go mod download && bash ./verify-moq-devcontainer.sh"
}
+1 -1
View File
@@ -14,7 +14,7 @@
"-action",
"run",
"-port",
"8080"
"8082"
],
"envFile": "${workspaceFolder}/machinery/.env.local",
"buildFlags": "--tags dynamic,moq",
+11 -3
View File
@@ -602,7 +602,10 @@ The `web` is a **React** project which is the main entry point for an end user t
cd ui
yarn start
This will start a webserver and launches the web app on port `3000`.
This starts the web app on port `3000`. In development, the UI connects to the
Agent API on port `8082`. Override that port when needed:
REACT_APP_AGENT_API_PORT=9090 yarn start
![login-agent](./assets/img/agent-login.gif)
@@ -618,9 +621,14 @@ You can simply run the `machinery` using following commands.
git clone https://github.com/kerberos-io/agent
cd machinery
go run main.go -action run -port 80
go run main.go -action run -port 8082
This will launch the Kerberos Agent and run a webserver on port `80`. You can change the port by your own preference. We strongly support the usage of [Goland](https://www.jetbrains.com/go/) or [Visual Studio Code](https://code.visualstudio.com/), as it comes with all the debugging and linting features built in.
This will launch the Kerberos Agent and run a webserver on port `8082`, matching
the UI development default. You can change the port together with
`REACT_APP_AGENT_API_PORT`. We strongly support the usage of
[Goland](https://www.jetbrains.com/go/) or
[Visual Studio Code](https://code.visualstudio.com/), as it comes with all the
debugging and linting features built in.
![VSCode desktop](./assets/img/vscode-desktop.png)
+55 -17
View File
@@ -1454,13 +1454,19 @@ func HandleRealtimeProcessing(processingCursor *packets.QueueCursor, configurati
// @Summary Will verify the hub connectivity.
// @Description Will verify the hub connectivity.
// @Success 200 {object} models.APIResponse
func VerifyHub(c *gin.Context) {
func VerifyHub(c *gin.Context, configuredHubURI string) {
var config models.Config
err := c.BindJSON(&config)
if err == nil {
hubURI := config.HubURI
hubURI, err := configuredHubVerificationURI(config.HubURI, configuredHubURI)
if err != nil {
c.JSON(http.StatusBadRequest, models.APIResponse{
Data: "cloud.VerifyHub(): " + err.Error(),
})
return
}
publicKey := config.HubKey
privateKey := config.HubPrivateKey
@@ -1473,9 +1479,9 @@ func VerifyHub(c *gin.Context) {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client = &http.Client{Transport: tr}
client = &http.Client{Transport: tr, CheckRedirect: rejectHubVerificationRedirect}
} else {
client = &http.Client{}
client = &http.Client{CheckRedirect: rejectHubVerificationRedirect}
}
resp, err := client.Do(req)
@@ -1524,7 +1530,7 @@ func VerifyHub(c *gin.Context) {
// @Summary Will verify the persistence.
// @Description Will verify the persistence.
// @Success 200 {object} models.APIResponse
func VerifyPersistence(c *gin.Context, configDirectory string) {
func VerifyPersistence(c *gin.Context, configDirectory, configuredHubURI string) {
var config models.Config
err := c.BindJSON(&config)
@@ -1537,6 +1543,7 @@ func VerifyPersistence(c *gin.Context, configDirectory string) {
if config.HubURI == "" ||
config.HubKey == "" ||
config.HubPrivateKey == "" ||
config.S3 == nil ||
config.S3.Region == "" {
msg := "cloud.VerifyPersistence(kerberoshub): Kerberos Hub not properly configured."
log.Error(msg)
@@ -1544,25 +1551,24 @@ func VerifyPersistence(c *gin.Context, configDirectory string) {
Data: msg,
})
} else {
// Open test-480p.mp4
file, err := os.Open(configDirectory + "/data/test-480p.mp4")
hubURI, err := configuredHubVerificationURI(config.HubURI, configuredHubURI)
if err != nil {
msg := "cloud.VerifyPersistence(kerberoshub): error reading test-480p.mp4: " + err.Error()
msg := "cloud.VerifyPersistence(kerberoshub): " + err.Error()
log.Error(msg)
c.JSON(400, models.APIResponse{
c.JSON(http.StatusBadRequest, models.APIResponse{
Data: msg,
})
return
}
defer file.Close()
req, err := http.NewRequest("POST", config.HubURI+"/storage/upload", file)
req, err := http.NewRequest(http.MethodHead, hubURI+"/storage/upload", nil)
if err != nil {
msg := "cloud.VerifyPersistence(kerberoshub): error reading Kerberos Hub HEAD request, " + config.HubURI + "/storage: " + err.Error()
msg := "cloud.VerifyPersistence(kerberoshub): error creating Kerberos Hub upload authorization request: " + err.Error()
log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
})
return
}
timestamp := time.Now().Unix()
@@ -1580,9 +1586,9 @@ func VerifyPersistence(c *gin.Context, configDirectory string) {
tr := &http.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: true},
}
client = &http.Client{Transport: tr}
client = &http.Client{Transport: tr, CheckRedirect: rejectHubVerificationRedirect}
} else {
client = &http.Client{}
client = &http.Client{CheckRedirect: rejectHubVerificationRedirect}
}
resp, err := client.Do(req)
@@ -1598,14 +1604,17 @@ func VerifyPersistence(c *gin.Context, configDirectory string) {
Data: msg,
})
} else {
msg := "cloud.VerifyPersistence(kerberoshub): upload not allowed using the configured credentials"
msg := hubPersistenceVerificationError(resp.StatusCode)
log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
})
}
} else {
msg := "cloud.VerifyPersistence(kerberoshub): Error creating Kerberos Hub request"
msg := "cloud.VerifyPersistence(kerberoshub): Kerberos Hub upload authorization request failed"
if err != nil {
msg += ": " + err.Error()
}
log.Error(msg)
c.JSON(400, models.APIResponse{
Data: msg,
@@ -1774,6 +1783,35 @@ func VerifyPersistence(c *gin.Context, configDirectory string) {
}
}
func configuredHubVerificationURI(requested, configured string) (string, error) {
requested = strings.TrimRight(strings.TrimSpace(requested), "/")
configured = strings.TrimRight(strings.TrimSpace(configured), "/")
if configured == "" {
return "", errors.New("save the Hub URI before verifying it")
}
if requested != configured {
return "", errors.New("the Hub URI does not match the saved configuration; save it before verifying")
}
return configured, nil
}
func rejectHubVerificationRedirect(_ *http.Request, _ []*http.Request) error {
return http.ErrUseLastResponse
}
func hubPersistenceVerificationError(statusCode int) string {
reason := "Kerberos Hub rejected upload authorization"
switch statusCode {
case http.StatusBadRequest:
reason += ": the credentials or device/project assignment are invalid, or the storage limit was reached"
case http.StatusUnauthorized:
reason += ": the account has no active subscription or the subscription expired"
case http.StatusForbidden:
reason += ": the device is muted"
}
return "cloud.VerifyPersistence(kerberoshub): " + reason + " (HTTP " + strconv.Itoa(statusCode) + ")"
}
// VerifySecondaryPersistence godoc
// @Router /api/persistence/secondary/verify [post]
// @ID verify-secondary-persistence
+199
View File
@@ -1,15 +1,18 @@
package cloud
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/gin-gonic/gin"
"github.com/kerberos-io/agent/machinery/src/models"
agentonvif "github.com/kerberos-io/agent/machinery/src/onvif"
goonvif "github.com/kerberos-io/onvif"
@@ -18,6 +21,202 @@ import (
goonvifxsd "github.com/kerberos-io/onvif/xsd/onvif"
)
func TestVerifyPersistenceKerberosHubUsesAuthorizationHead(t *testing.T) {
var requestMethod string
var requestBody []byte
var requestHeaders http.Header
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
requestMethod = r.Method
requestHeaders = r.Header.Clone()
requestBody, _ = io.ReadAll(r.Body)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()
config := models.Config{
Cloud: "kerberoshub",
Key: "camera-1",
Name: "front-door",
HubURI: server.URL,
HubKey: "project-public",
HubPrivateKey: "project-private",
S3: &models.S3{Region: "eu"},
}
payload, err := json.Marshal(config)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/persistence/verify", bytes.NewReader(payload))
ctx.Request.Header.Set("Content-Type", "application/json")
VerifyPersistence(ctx, t.TempDir(), server.URL)
if recorder.Code != http.StatusOK {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
if requestMethod != http.MethodHead {
t.Fatalf("Hub request method = %q, want HEAD", requestMethod)
}
if len(requestBody) != 0 {
t.Fatalf("Hub request body has %d bytes, want none", len(requestBody))
}
for header, want := range map[string]string{
"X-Kerberos-Storage-Device": "camera-1",
"X-Kerberos-Hub-PublicKey": "project-public",
"X-Kerberos-Hub-PrivateKey": "project-private",
"X-Kerberos-Hub-Region": "eu",
"X-Kerberos-Storage-Capture": "IPCamera",
} {
if got := requestHeaders.Get(header); got != want {
t.Errorf("%s = %q, want %q", header, got, want)
}
}
}
func TestVerifyPersistenceKerberosHubRejectsCrossHostRedirect(t *testing.T) {
var redirectedHeaders http.Header
target := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
redirectedHeaders = r.Header.Clone()
w.WriteHeader(http.StatusOK)
}))
defer target.Close()
var originalHeaders http.Header
hub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
originalHeaders = r.Header.Clone()
http.Redirect(w, r, target.URL+"/storage/upload", http.StatusTemporaryRedirect)
}))
defer hub.Close()
config := models.Config{
Cloud: "kerberoshub",
Key: "camera-1",
Name: "front-door",
HubURI: hub.URL,
HubKey: "project-public",
HubPrivateKey: "project-private",
S3: &models.S3{Region: "eu"},
}
payload, err := json.Marshal(config)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/persistence/verify", bytes.NewReader(payload))
ctx.Request.Header.Set("Content-Type", "application/json")
VerifyPersistence(ctx, t.TempDir(), hub.URL)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body = %s", recorder.Code, http.StatusBadRequest, recorder.Body.String())
}
for _, header := range []string{"X-Kerberos-Hub-PublicKey", "X-Kerberos-Hub-PrivateKey"} {
if originalHeaders.Get(header) == "" {
t.Errorf("%s was not sent to the configured Hub", header)
}
if redirectedHeaders != nil {
t.Errorf("redirect target received a request with %s=%q", header, redirectedHeaders.Get(header))
}
}
}
func TestVerifyPersistenceKerberosHubRejectsUnsavedURI(t *testing.T) {
requestCount := 0
untrusted := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requestCount++
w.WriteHeader(http.StatusOK)
}))
defer untrusted.Close()
config := models.Config{
Cloud: "kerberoshub",
Key: "camera-1",
Name: "front-door",
HubURI: untrusted.URL,
HubKey: "project-public",
HubPrivateKey: "project-private",
S3: &models.S3{Region: "eu"},
}
payload, err := json.Marshal(config)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/persistence/verify", bytes.NewReader(payload))
ctx.Request.Header.Set("Content-Type", "application/json")
VerifyPersistence(ctx, t.TempDir(), "https://saved-hub.example.com")
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body = %s", recorder.Code, http.StatusBadRequest, recorder.Body.String())
}
if requestCount != 0 {
t.Fatalf("untrusted Hub received %d requests, want none", requestCount)
}
}
func TestVerifyHubRejectsUnsavedURI(t *testing.T) {
requestCount := 0
untrusted := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
requestCount++
w.WriteHeader(http.StatusOK)
}))
defer untrusted.Close()
config := models.Config{
HubURI: untrusted.URL,
HubKey: "project-public",
HubPrivateKey: "project-private",
}
payload, err := json.Marshal(config)
if err != nil {
t.Fatalf("json.Marshal() error = %v", err)
}
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/hub/verify", bytes.NewReader(payload))
ctx.Request.Header.Set("Content-Type", "application/json")
VerifyHub(ctx, "https://saved-hub.example.com")
if recorder.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want %d; body = %s", recorder.Code, http.StatusBadRequest, recorder.Body.String())
}
if requestCount != 0 {
t.Fatalf("untrusted Hub received %d requests, want none", requestCount)
}
}
func TestHubPersistenceVerificationErrorExplainsStatus(t *testing.T) {
for _, test := range []struct {
status int
want string
}{
{status: http.StatusBadRequest, want: "credentials or device/project assignment"},
{status: http.StatusUnauthorized, want: "no active subscription"},
{status: http.StatusForbidden, want: "device is muted"},
{status: http.StatusBadGateway, want: "HTTP 502"},
} {
t.Run(http.StatusText(test.status), func(t *testing.T) {
if got := hubPersistenceVerificationError(test.status); !strings.Contains(got, test.want) {
t.Fatalf("hubPersistenceVerificationError(%d) = %q, want it to contain %q", test.status, got, test.want)
}
})
}
}
func TestHeartbeatFailureLogOmitsHubResponseBody(t *testing.T) {
response := &http.Response{
StatusCode: http.StatusBadRequest,
+2 -2
View File
@@ -65,12 +65,12 @@ func AddRoutes(r *gin.Engine, authMiddleware *jwt.GinJWTMiddleware, configDirect
// Will verify the hub settings.
api.POST("/hub/verify", func(c *gin.Context) {
cloud.VerifyHub(c)
cloud.VerifyHub(c, configuration.Config.HubURI)
})
// Will verify the persistence settings.
api.POST("/persistence/verify", func(c *gin.Context) {
cloud.VerifyPersistence(c, configDirectory)
cloud.VerifyPersistence(c, configDirectory, configuration.Config.HubURI)
})
// Will verify the secondary persistence settings.
+4 -3
View File
@@ -1,5 +1,6 @@
const { hostname, host, protocol } = window.location;
const websocketprotocol = protocol === 'http:' ? 'ws:' : 'wss:';
const devAgentPort = process.env.REACT_APP_AGENT_API_PORT || '8082';
// Uncomment this when using codespaces or other special DNS names (which you can't control)
// replace this with the DNS name of the kerberos agent server (the codespace url)
@@ -9,9 +10,9 @@ const dev = {
ENV: 'dev',
// Comment the below lines, when using codespaces or other special DNS names (which you can't control)
HOSTNAME: hostname,
API_URL: `${protocol}//${hostname}:8080/api`,
URL: `${protocol}//${hostname}:8080`,
WS_URL: `${websocketprotocol}//${hostname}:8080/ws`,
API_URL: `${protocol}//${hostname}:${devAgentPort}/api`,
URL: `${protocol}//${hostname}:${devAgentPort}`,
WS_URL: `${websocketprotocol}//${hostname}:${devAgentPort}/ws`,
MODE: window['env']['mode'],
// Uncomment, and comment the above lines, when using codespaces or other special DNS names (which you can't control)
// HOSTNAME: externalHost,