feat!: standardize ports to range 8180-8124 for cluster comms and API

This commit is contained in:
hayzam
2026-03-18 20:37:40 +05:30
parent 28161db905
commit f34b2991a7
26 changed files with 5538 additions and 306 deletions
+21 -30
View File
@@ -15,7 +15,6 @@ import (
"net/http"
"os"
"os/signal"
"strings"
"sync"
"syscall"
"time"
@@ -35,13 +34,14 @@ import (
"github.com/alchemillahq/sylve/internal/services/jail"
"github.com/alchemillahq/sylve/internal/services/libvirt"
"github.com/alchemillahq/sylve/internal/services/lifecycle"
"github.com/alchemillahq/sylve/internal/services/network"
networkService "github.com/alchemillahq/sylve/internal/services/network"
"github.com/alchemillahq/sylve/internal/services/samba"
"github.com/alchemillahq/sylve/internal/services/system"
"github.com/alchemillahq/sylve/internal/services/utilities"
"github.com/alchemillahq/sylve/internal/services/zelta"
"github.com/alchemillahq/sylve/internal/services/zfs"
portnetwork "github.com/alchemillahq/sylve/pkg/network"
sysU "github.com/alchemillahq/sylve/pkg/system"
"github.com/gin-contrib/gzip"
"github.com/gin-gonic/gin"
@@ -58,6 +58,9 @@ func main() {
cfg := config.ParseConfig(cfgPath)
logger.InitLogger(cfg.DataPath, cfg.LogLevel)
if err := preflightRequiredPorts(cfg, portnetwork.TryBindToPort); err != nil {
logger.L.Fatal().Err(err).Msg("startup_port_preflight_failed")
}
d := db.SetupDatabase(cfg, false)
_ = db.SetupCache(cfg)
@@ -95,6 +98,10 @@ func main() {
zeltaS := serviceRegistry.ZeltaService
clusterSvc := cS.(*cluster.Service)
if err := clusterSvc.MigrateLegacyPorts(); err != nil {
logger.L.Fatal().Err(err).Msg("failed_to_migrate_legacy_cluster_ports")
}
jailSvc := jS.(*jail.Service)
libvirtSvc := lvS.(*libvirt.Service)
lifecycleSvc := lifecycle.NewService(d, libvirtSvc, jailSvc)
@@ -133,11 +140,7 @@ func main() {
err = cS.InitRaft(fsm)
if err != nil {
if !strings.Contains(err.Error(), "record not found") {
logger.L.Error().Err(err).Msg("Failed to initialize RAFT")
} else {
logger.L.Info().Msg("Not initializing RAFT")
}
logger.L.Fatal().Err(err).Msg("Failed to initialize RAFT")
}
if err := clusterSvc.StartEmbeddedSSHServer(qCtx); err != nil {
@@ -169,7 +172,7 @@ func main() {
iS.(*info.Service),
zS.(*zfs.Service),
dS.(*disk.Service),
nS.(*network.Service),
nS.(*networkService.Service),
uS.(*utilities.Service),
sysS.(*system.Service),
libvirtSvc,
@@ -190,7 +193,7 @@ func main() {
Auth: aS.(*auth.Service),
Jail: jailSvc,
VirtualMachine: libvirtSvc,
Network: nS.(*network.Service),
Network: nS.(*networkService.Service),
QuitChan: sigChan,
}
@@ -220,13 +223,6 @@ func main() {
TLSConfig: tlsConfig,
}
if cfg.HTTPPort == cluster.ClusterEmbeddedHTTPSPort {
logger.L.Fatal().
Int("http_port", cfg.HTTPPort).
Int("intra_cluster_https_port", cluster.ClusterEmbeddedHTTPSPort).
Msg("Configured HTTP port conflicts with reserved intra-cluster HTTPS port")
}
var wg sync.WaitGroup
type namedServer struct {
name string
@@ -258,20 +254,15 @@ func main() {
}()
}
startDedicatedClusterHTTPS := cfg.Port == 0 || cfg.Port != cluster.ClusterEmbeddedHTTPSPort
if startDedicatedClusterHTTPS {
startedServers = append(startedServers, namedServer{name: "Intra-cluster HTTPS", srv: clusterHTTPSServer})
wg.Add(1)
go func() {
defer wg.Done()
logger.L.Info().Msgf("Intra-cluster HTTPS server started on %s:%d", cfg.IP, cluster.ClusterEmbeddedHTTPSPort)
if err := clusterHTTPSServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
logger.L.Fatal().Err(err).Msg("Failed to start intra-cluster HTTPS server")
}
}()
} else {
logger.L.Info().Msgf("Intra-cluster HTTPS is served by configured HTTPS port on %s:%d", cfg.IP, cfg.Port)
}
startedServers = append(startedServers, namedServer{name: "Intra-cluster HTTPS", srv: clusterHTTPSServer})
wg.Add(1)
go func() {
defer wg.Done()
logger.L.Info().Msgf("Intra-cluster HTTPS server started on %s:%d", cfg.IP, cluster.ClusterEmbeddedHTTPSPort)
if err := clusterHTTPSServer.ListenAndServeTLS("", ""); err != nil && err != http.ErrServerClosed {
logger.L.Fatal().Err(err).Msg("Failed to start intra-cluster HTTPS server")
}
}()
<-sigChan
+102
View File
@@ -0,0 +1,102 @@
// SPDX-License-Identifier: BSD-2-Clause
//
// Copyright (c) 2025 The FreeBSD Foundation.
//
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
// under sponsorship from the FreeBSD Foundation.
package main
import (
"fmt"
"sort"
"strings"
"github.com/alchemillahq/sylve/internal"
"github.com/alchemillahq/sylve/internal/services/cluster"
"github.com/alchemillahq/sylve/pkg/utils"
)
type portRequirement struct {
role string
port int
}
type portBinder func(ip string, port int, proto string) error
func preflightRequiredPorts(cfg *internal.SylveConfig, binder portBinder) error {
reqs, err := buildPortRequirements(cfg)
if err != nil {
return err
}
for _, req := range reqs {
if err := binder("", req.port, "tcp"); err != nil {
return fmt.Errorf("required_port_not_bindable role=%s port=%d: %w", req.role, req.port, err)
}
}
return nil
}
func buildPortRequirements(cfg *internal.SylveConfig) ([]portRequirement, error) {
if cfg == nil {
return nil, fmt.Errorf("config_required")
}
reqs := make([]portRequirement, 0, 5)
if cfg.HTTPPort != 0 {
if !utils.IsValidPort(cfg.HTTPPort) {
return nil, fmt.Errorf("invalid_http_port: %d", cfg.HTTPPort)
}
reqs = append(reqs, portRequirement{role: "http", port: cfg.HTTPPort})
}
if cfg.Port != 0 {
if !utils.IsValidPort(cfg.Port) {
return nil, fmt.Errorf("invalid_https_port: %d", cfg.Port)
}
reqs = append(reqs, portRequirement{role: "https", port: cfg.Port})
}
reqs = append(reqs,
portRequirement{role: "cluster_ssh", port: cluster.ClusterEmbeddedSSHPort},
portRequirement{role: "raft", port: cluster.ClusterRaftPort},
portRequirement{role: "cluster_https", port: cluster.ClusterEmbeddedHTTPSPort},
)
for _, req := range reqs {
if !utils.IsValidPort(req.port) {
return nil, fmt.Errorf("invalid_required_port role=%s port=%d", req.role, req.port)
}
}
roleByPort := make(map[int][]string, len(reqs))
for _, req := range reqs {
roleByPort[req.port] = append(roleByPort[req.port], req.role)
}
ports := make([]int, 0, len(roleByPort))
for port := range roleByPort {
ports = append(ports, port)
}
sort.Ints(ports)
conflicts := make([]string, 0)
for _, port := range ports {
roles := roleByPort[port]
if len(roles) <= 1 {
continue
}
sort.Strings(roles)
conflicts = append(conflicts, fmt.Sprintf("port=%d roles=%s", port, strings.Join(roles, ",")))
}
if len(conflicts) > 0 {
return nil, fmt.Errorf("port_role_collision: %s", strings.Join(conflicts, "; "))
}
return reqs, nil
}
+149
View File
@@ -0,0 +1,149 @@
// SPDX-License-Identifier: BSD-2-Clause
//
// Copyright (c) 2025 The FreeBSD Foundation.
//
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
// under sponsorship from the FreeBSD Foundation.
package main
import (
"errors"
"strings"
"testing"
"github.com/alchemillahq/sylve/internal"
"github.com/alchemillahq/sylve/internal/services/cluster"
)
func TestBuildPortRequirementsIncludesConfiguredAndFixedPorts(t *testing.T) {
cfg := &internal.SylveConfig{
Port: 8181,
HTTPPort: 8182,
}
reqs, err := buildPortRequirements(cfg)
if err != nil {
t.Fatalf("buildPortRequirements returned error: %v", err)
}
rolesByPort := map[int]string{}
for _, req := range reqs {
rolesByPort[req.port] = req.role
}
expected := map[int]string{
8181: "https",
8182: "http",
cluster.ClusterRaftPort: "raft",
cluster.ClusterEmbeddedSSHPort: "cluster_ssh",
cluster.ClusterEmbeddedHTTPSPort: "cluster_https",
}
if len(rolesByPort) != len(expected) {
t.Fatalf("expected %d unique ports, got %d", len(expected), len(rolesByPort))
}
for port, role := range expected {
gotRole, ok := rolesByPort[port]
if !ok {
t.Fatalf("missing required role %q on port %d", role, port)
}
if gotRole != role {
t.Fatalf("unexpected role for port %d: expected %q got %q", port, role, gotRole)
}
}
}
func TestBuildPortRequirementsAllowsDisabledHTTPAndHTTPS(t *testing.T) {
cfg := &internal.SylveConfig{Port: 0, HTTPPort: 0}
reqs, err := buildPortRequirements(cfg)
if err != nil {
t.Fatalf("buildPortRequirements returned error: %v", err)
}
if len(reqs) != 3 {
t.Fatalf("expected only fixed cluster ports when HTTP/HTTPS disabled, got %d", len(reqs))
}
ports := map[int]struct{}{}
for _, req := range reqs {
ports[req.port] = struct{}{}
}
for _, requiredPort := range []int{cluster.ClusterRaftPort, cluster.ClusterEmbeddedSSHPort, cluster.ClusterEmbeddedHTTPSPort} {
if _, ok := ports[requiredPort]; !ok {
t.Fatalf("missing required fixed port %d", requiredPort)
}
}
}
func TestBuildPortRequirementsDetectsRoleCollision(t *testing.T) {
cfg := &internal.SylveConfig{
Port: cluster.ClusterEmbeddedHTTPSPort,
HTTPPort: 8182,
}
_, err := buildPortRequirements(cfg)
if err == nil {
t.Fatal("expected collision error, got nil")
}
if !strings.Contains(err.Error(), "port_role_collision") {
t.Fatalf("expected port_role_collision error, got: %v", err)
}
if !strings.Contains(err.Error(), "cluster_https") || !strings.Contains(err.Error(), "https") {
t.Fatalf("expected collision error to include colliding roles, got: %v", err)
}
}
func TestPreflightRequiredPortsFailsOnBindError(t *testing.T) {
cfg := &internal.SylveConfig{
Port: 8181,
HTTPPort: 8182,
}
err := preflightRequiredPorts(cfg, func(_ string, port int, proto string) error {
if proto != "tcp" {
t.Fatalf("expected tcp bind checks, got %q", proto)
}
if port == cluster.ClusterRaftPort {
return errors.New("already in use")
}
return nil
})
if err == nil {
t.Fatal("expected preflight bind error, got nil")
}
if !strings.Contains(err.Error(), "role=raft") || !strings.Contains(err.Error(), "port=8180") {
t.Fatalf("expected role-specific bind failure, got: %v", err)
}
}
func TestPreflightRequiredPortsChecksAllExpectedRoles(t *testing.T) {
cfg := &internal.SylveConfig{
Port: 8181,
HTTPPort: 8182,
}
called := map[int]struct{}{}
err := preflightRequiredPorts(cfg, func(_ string, port int, proto string) error {
if proto != "tcp" {
t.Fatalf("expected tcp bind checks, got %q", proto)
}
called[port] = struct{}{}
return nil
})
if err != nil {
t.Fatalf("preflightRequiredPorts returned error: %v", err)
}
for _, expectedPort := range []int{8181, 8182, cluster.ClusterRaftPort, cluster.ClusterEmbeddedSSHPort, cluster.ClusterEmbeddedHTTPSPort} {
if _, ok := called[expectedPort]; !ok {
t.Fatalf("missing bind check for port %d", expectedPort)
}
}
}
@@ -114,8 +114,8 @@ We will setup an `rc` script for running Sylve but prior to that we need to do 2
"keyFile": "/usr/local/etc/letsencrypt/live/sylve.example.net/privkey.pem",
},
"logLevel": 3,
"port": 8181, // You can set to 0 to disable HTTPS entirely
"httpPort": 8182, // You can set to 0 to disable HTTP entirely
"port": 8181, // You can set to 0 to disable the main/public HTTPS listener
"httpPort": 8182, // You can set to 0 to disable the main/public HTTP listener
"raft": {
"reset": false,
},
@@ -284,6 +284,10 @@ Sylve uses these ports by default:
- `8182/tcp`: Main API and web UI over HTTP only. This is insecure and can break features like passkeys and serial/VNC console, so use it only when needed (for example, behind a reverse proxy on a trusted network). Change in `config.json` with `httpPort`.
- `8180/tcp`: RAFT communication between cluster nodes. You can choose this value during cluster setup, but every node must use the same RAFT port.
- `8180/tcp`: RAFT communication between cluster nodes. This port is fixed in Sylve and is not configurable.
- `8183/tcp`: Cluster SSH channel used for intra-cluster operations. This port is fixed in Sylve and is not configurable.
- `8184/tcp`: Intra-cluster HTTPS API used for node-to-node control plane traffic (including cluster join flows). This port is fixed in Sylve and is not configurable.
- `7246/udp`: BTT DHT peer discovery. Used only when `btt.dht.enabled` is `true`. Change in `config.json` with `btt.dht.port`.
@@ -23,7 +23,7 @@ The upcoming replication feature is also disabled on nodes with less than 3 node
## Setting up a Cluster
Setting up a cluster is pretty straightforward, all you need to do is to navigate to Datacenter -> Cluster and then click on "Create Cluster". Once you do that a modal opens up where you can fill out the IP of your node that will be used for clustering and the port as well, this port must be **identical** on all nodes in the cluster, the default port is 8180.
Setting up a cluster is pretty straightforward, all you need to do is to navigate to Datacenter -> Cluster and then click on "Create Cluster". Once you do that a modal opens up where you can fill out the IP of your node that will be used for clustering. RAFT uses the fixed intra-cluster port `8180/tcp` on every node.
![Creating a cluster](create-cluster.png)
@@ -39,7 +39,7 @@ Treat the cluster key like a password, anyone with the cluster key can join the
## Joining a Cluster
To join a cluster, copy the cluster key from the first node or the **LEADER NODE** and click on "Join Cluster" on the node that you want to join the cluster. This will open up a modal where you can paste the cluster key, and the API of the **LEADER NODE**, the API is NOT running on port 8180 or whatever your RAFT port is, it will be running on "192.168.172.203:8181" or whatever ip:port you use to access the WebUI of the leader. Once that is done click on "Join". Once you do that, the node will attempt to join the cluster and if successful, it will be added to the cluster view. This is what the join modal looks like:
To join a cluster, copy the cluster key from the first node or the **LEADER NODE** and click on "Join Cluster" on the node that you want to join the cluster. This opens a modal where you provide the cluster key and the IP of the **LEADER NODE** (IP only, no port). Sylve uses the fixed intra-cluster HTTPS port `8184/tcp` internally for leader API communication, and fixed RAFT port `8180/tcp` for consensus. Once that is done click on "Join". Once you do that, the node will attempt to join the cluster and if successful, it will be added to the cluster view. This is what the join modal looks like:
![Joining a cluster](join-cluster.png)
@@ -63,4 +63,4 @@ Once you join a cluster successfully, your `/datacenter/summary` dashboard shoul
## Testing out RAFT
We have a nice little "Notes" application similar to the Notes application found inside each node, but the difference with this one is that the data is RAFT replicated meaning if you make a note on one node it should eventually be present on all Nodes. This is the best litmus test to see if everything is fine before you proceed with more interesting things.
We have a nice little "Notes" application similar to the Notes application found inside each node, but the difference with this one is that the data is RAFT replicated meaning if you make a note on one node it should eventually be present on all Nodes. This is the best litmus test to see if everything is fine before you proceed with more interesting things.
+5 -17
View File
@@ -646,7 +646,7 @@ const docTemplate = `{
"BearerAuth": []
}
],
"description": "Create a cluster given a bootstrapping nodes IP and Port",
"description": "Create a cluster given a bootstrapping node IP",
"consumes": [
"application/json"
],
@@ -13301,8 +13301,7 @@ const docTemplate = `{
"required": [
"clusterKey",
"nodeId",
"nodeIp",
"nodePort"
"nodeIp"
],
"properties": {
"clusterKey": {
@@ -13313,11 +13312,6 @@ const docTemplate = `{
},
"nodeIp": {
"type": "string"
},
"nodePort": {
"type": "integer",
"maximum": 65535,
"minimum": 1024
}
}
},
@@ -13325,16 +13319,15 @@ const docTemplate = `{
"type": "object",
"required": [
"clusterKey",
"leaderApi",
"leaderIp",
"nodeId",
"nodeIp",
"nodePort"
"nodeIp"
],
"properties": {
"clusterKey": {
"type": "string"
},
"leaderApi": {
"leaderIp": {
"type": "string"
},
"nodeId": {
@@ -13342,11 +13335,6 @@ const docTemplate = `{
},
"nodeIp": {
"type": "string"
},
"nodePort": {
"type": "integer",
"maximum": 65535,
"minimum": 1024
}
}
},
+5 -17
View File
@@ -640,7 +640,7 @@
"BearerAuth": []
}
],
"description": "Create a cluster given a bootstrapping nodes IP and Port",
"description": "Create a cluster given a bootstrapping node IP",
"consumes": [
"application/json"
],
@@ -13295,8 +13295,7 @@
"required": [
"clusterKey",
"nodeId",
"nodeIp",
"nodePort"
"nodeIp"
],
"properties": {
"clusterKey": {
@@ -13307,11 +13306,6 @@
},
"nodeIp": {
"type": "string"
},
"nodePort": {
"type": "integer",
"maximum": 65535,
"minimum": 1024
}
}
},
@@ -13319,16 +13313,15 @@
"type": "object",
"required": [
"clusterKey",
"leaderApi",
"leaderIp",
"nodeId",
"nodeIp",
"nodePort"
"nodeIp"
],
"properties": {
"clusterKey": {
"type": "string"
},
"leaderApi": {
"leaderIp": {
"type": "string"
},
"nodeId": {
@@ -13336,11 +13329,6 @@
},
"nodeIp": {
"type": "string"
},
"nodePort": {
"type": "integer",
"maximum": 65535,
"minimum": 1024
}
}
},
+3 -13
View File
@@ -3221,36 +3221,26 @@ definitions:
type: string
nodeIp:
type: string
nodePort:
maximum: 65535
minimum: 1024
type: integer
required:
- clusterKey
- nodeId
- nodeIp
- nodePort
type: object
internal_handlers_cluster.JoinClusterRequest:
properties:
clusterKey:
type: string
leaderApi:
leaderIp:
type: string
nodeId:
type: string
nodeIp:
type: string
nodePort:
maximum: 65535
minimum: 1024
type: integer
required:
- clusterKey
- leaderApi
- leaderIp
- nodeId
- nodeIp
- nodePort
type: object
internal_handlers_cluster.NoteRequest:
properties:
@@ -4293,7 +4283,7 @@ paths:
post:
consumes:
- application/json
description: Create a cluster given a bootstrapping nodes IP and Port
description: Create a cluster given a bootstrapping node IP
produces:
- application/json
responses:
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+1 -1
View File
@@ -301,7 +301,7 @@ func initClusterRecord(db *gorm.DB) error {
Key: "",
RaftBootstrap: nil,
RaftIP: "",
RaftPort: 0,
RaftPort: 8180,
}
if err := db.Create(defaultCluster).Error; err != nil {
+2 -2
View File
@@ -159,7 +159,7 @@ type ClusterSSHIdentity struct {
NodeUUID string `gorm:"uniqueIndex;not null" json:"nodeUUID"`
SSHUser string `gorm:"not null;default:root" json:"sshUser"`
SSHHost string `gorm:"not null" json:"sshHost"`
SSHPort int `gorm:"not null;default:8122" json:"sshPort"`
SSHPort int `gorm:"not null;default:8183" json:"sshPort"`
PublicKey string `gorm:"type:text;not null" json:"publicKey"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"createdAt"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updatedAt"`
@@ -414,7 +414,7 @@ func upsertClusterSSHIdentity(db *gorm.DB, identity *ClusterSSHIdentity) error {
identity.SSHUser = "root"
}
if identity.SSHPort == 0 {
identity.SSHPort = 8122
identity.SSHPort = 8183
}
if identity.NodeUUID == "" {
+17 -15
View File
@@ -24,22 +24,19 @@ import (
)
type CreateClusterRequest struct {
IP string `json:"ip" binding:"required,ip"`
Port int `json:"port" binding:"required,min=1024,max=65535"`
IP string `json:"ip" binding:"required,ip"`
}
type JoinClusterRequest struct {
NodeID string `json:"nodeId" binding:"required"`
NodeIP string `json:"nodeIp" binding:"required,ip"`
NodePort int `json:"nodePort" binding:"required,min=1024,max=65535"`
LeaderAPI string `json:"leaderApi" binding:"required"`
LeaderIP string `json:"leaderIp" binding:"required,ip"`
ClusterKey string `json:"clusterKey" binding:"required"`
}
type AcceptJoinRequest struct {
NodeID string `json:"nodeId" binding:"required"`
NodeIP string `json:"nodeIp" binding:"required,ip"`
NodePort int `json:"nodePort" binding:"required,min=1024,max=65535"`
ClusterKey string `json:"clusterKey" binding:"required"`
}
@@ -47,6 +44,10 @@ type RemovePeerRequest struct {
NodeID string `json:"nodeId" binding:"required"`
}
func joinLeaderAPIHost(leaderIP string) string {
return cluster.ClusterAPIHost(leaderIP)
}
// @Summary Get Cluster
// @Description Get cluster details with information about RAFT nodes too
// @Tags Cluster
@@ -79,7 +80,7 @@ func GetCluster(cS *cluster.Service) gin.HandlerFunc {
}
// @Summary Create Cluster
// @Description Create a cluster given a bootstrapping nodes IP and Port
// @Description Create a cluster given a bootstrapping node IP
// @Tags Cluster
// @Accept json
// @Produce json
@@ -101,7 +102,7 @@ func CreateCluster(as *auth.Service, cS *cluster.Service, fsm raft.FSM) gin.Hand
return
}
if err := cS.CreateCluster(req.IP, req.Port, fsm); err != nil {
if err := cS.CreateCluster(req.IP, fsm); err != nil {
c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{
Status: "error",
Message: "error_creating_cluster",
@@ -170,16 +171,18 @@ func JoinCluster(aS *auth.Service, cS *cluster.Service, zS *zelta.Service, fsm r
return
}
if !utils.IsValidIPPort(req.LeaderAPI) {
if !utils.IsValidIP(req.LeaderIP) {
c.JSON(http.StatusBadRequest, internal.APIResponse[any]{
Status: "error",
Message: "invalid_leader_api",
Error: "leader_api_must_be_in_host_port_format",
Message: "invalid_leader_ip",
Error: "leader_ip_must_be_valid",
Data: nil,
})
return
}
leaderAPIHost := joinLeaderAPIHost(req.LeaderIP)
userId := c.GetUint("UserID")
username := c.GetString("Username")
authType := c.GetString("AuthType")
@@ -190,7 +193,7 @@ func JoinCluster(aS *auth.Service, cS *cluster.Service, zS *zelta.Service, fsm r
healthURL := fmt.Sprintf(
"https://%s/api/health/basic",
req.LeaderAPI,
leaderAPIHost,
)
if err := utils.HTTPPostJSON(healthURL, req, headers); err != nil {
@@ -203,7 +206,7 @@ func JoinCluster(aS *auth.Service, cS *cluster.Service, zS *zelta.Service, fsm r
return
}
err = cS.StartAsJoiner(fsm, req.NodeIP, req.NodePort, req.ClusterKey)
err = cS.StartAsJoiner(fsm, req.NodeIP, req.ClusterKey)
if err != nil {
c.JSON(http.StatusInternalServerError, internal.APIResponse[any]{
Status: "error",
@@ -214,11 +217,10 @@ func JoinCluster(aS *auth.Service, cS *cluster.Service, zS *zelta.Service, fsm r
return
}
acceptURL := fmt.Sprintf("https://%s/api/cluster/accept-join", req.LeaderAPI)
acceptURL := fmt.Sprintf("https://%s/api/cluster/accept-join", leaderAPIHost)
payload := map[string]any{
"nodeId": req.NodeID,
"nodeIp": req.NodeIP,
"nodePort": req.NodePort,
"clusterKey": req.ClusterKey,
}
@@ -275,7 +277,7 @@ func AcceptJoin(cS *cluster.Service) gin.HandlerFunc {
return
}
if err := cS.AcceptJoin(req.NodeID, req.NodeIP, req.NodePort, req.ClusterKey); err != nil {
if err := cS.AcceptJoin(req.NodeID, req.NodeIP, req.ClusterKey); err != nil {
if strings.HasPrefix(err.Error(), "not_leader;") {
c.JSON(http.StatusConflict, internal.APIResponse[any]{
Status: "error",
@@ -0,0 +1,108 @@
// SPDX-License-Identifier: BSD-2-Clause
//
// Copyright (c) 2025 The FreeBSD Foundation.
//
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
// under sponsorship from the FreeBSD Foundation.
package clusterHandlers
import (
"encoding/json"
"net"
"net/http"
"strconv"
"testing"
"github.com/alchemillahq/sylve/internal/services/cluster"
"github.com/gin-gonic/gin"
)
func newClusterLifecycleValidationRouter() *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.POST("/cluster", CreateCluster(nil, nil, nil))
r.POST("/cluster/join", JoinCluster(nil, nil, nil, nil))
r.POST("/cluster/accept-join", AcceptJoin(nil))
return r
}
func TestCreateClusterRejectsPayloadWithoutIP(t *testing.T) {
r := newClusterLifecycleValidationRouter()
rr := performJSONRequest(t, r, http.MethodPost, "/cluster", []byte(`{"port":8180}`))
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d with body %s", rr.Code, rr.Body.String())
}
var resp handlerAPIResponse[any]
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid json response: %v", err)
}
if resp.Message != "invalid_request_payload" {
t.Fatalf("expected invalid_request_payload, got %q", resp.Message)
}
}
func TestJoinClusterRejectsLegacyLeaderApiPayload(t *testing.T) {
r := newClusterLifecycleValidationRouter()
body := []byte(`{"nodeId":"node-1","nodeIp":"10.0.0.2","leaderApi":"10.0.0.1:8184","nodePort":8180,"clusterKey":"secret"}`)
rr := performJSONRequest(t, r, http.MethodPost, "/cluster/join", body)
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d with body %s", rr.Code, rr.Body.String())
}
var resp handlerAPIResponse[any]
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid json response: %v", err)
}
if resp.Message != "invalid_request_payload" {
t.Fatalf("expected invalid_request_payload, got %q", resp.Message)
}
}
func TestAcceptJoinRejectsPayloadWithoutNodeIP(t *testing.T) {
r := newClusterLifecycleValidationRouter()
rr := performJSONRequest(t, r, http.MethodPost, "/cluster/accept-join", []byte(`{"nodeId":"node-1","clusterKey":"secret"}`))
if rr.Code != http.StatusBadRequest {
t.Fatalf("expected status 400, got %d with body %s", rr.Code, rr.Body.String())
}
var resp handlerAPIResponse[any]
if err := json.Unmarshal(rr.Body.Bytes(), &resp); err != nil {
t.Fatalf("invalid json response: %v", err)
}
if resp.Message != "invalid_request_payload" {
t.Fatalf("expected invalid_request_payload, got %q", resp.Message)
}
}
func TestJoinLeaderAPIHostUsesClusterHTTPSPort(t *testing.T) {
tests := []struct {
name string
ip string
}{
{name: "ipv4", ip: "10.0.0.9"},
{name: "ipv6", ip: "fd00::9"},
{name: "trimmed", ip: " 192.168.10.20 "},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hostPort := joinLeaderAPIHost(tt.ip)
host, port, err := net.SplitHostPort(hostPort)
if err != nil {
t.Fatalf("SplitHostPort failed for %q: %v", hostPort, err)
}
if host == "" {
t.Fatal("expected non-empty host")
}
if port != strconv.Itoa(cluster.ClusterEmbeddedHTTPSPort) {
t.Fatalf("expected cluster HTTPS port %d, got %s", cluster.ClusterEmbeddedHTTPSPort, port)
}
})
}
}
@@ -48,7 +48,7 @@ type NodeResources struct {
type ClusterServiceInterface interface {
Detail() *Detail
InitRaft(fsm raft.FSM) error
CreateCluster(ip string, port int, fsm raft.FSM) error
CreateCluster(ip string, fsm raft.FSM) error
SetupRaft(bootstrap bool, fsm raft.FSM) (*raft.Raft, error)
GetClusterDetails() (*ClusterDetails, error)
PopulateClusterNodes() error
+9 -9
View File
@@ -346,11 +346,13 @@ func (s *Service) ResyncClusterState() error {
return nil
}
func (s *Service) CreateCluster(ip string, port int, fsm raft.FSM) error {
func (s *Service) CreateCluster(ip string, fsm raft.FSM) error {
if s.Raft != nil {
return errors.New("raft_already_initialized")
}
port := ClusterRaftPort
if err := network.TryBindToPort(ip, port, "tcp"); err != nil {
return err
}
@@ -419,14 +421,12 @@ func (s *Service) CreateCluster(ip string, port int, fsm raft.FSM) error {
return nil
}
func (s *Service) StartAsJoiner(fsm raft.FSM, ip string, port int, clusterKey string) error {
func (s *Service) StartAsJoiner(fsm raft.FSM, ip string, clusterKey string) error {
if !utils.IsValidIP(ip) {
return errors.New("invalid_ip_address")
}
if !utils.IsValidPort(port) {
return errors.New("invalid_port_number")
}
port := ClusterRaftPort
if err := network.TryBindToPort(ip, port, "tcp"); err != nil {
return fmt.Errorf("failed_to_bind_to_port: %v", err)
@@ -471,7 +471,7 @@ func (s *Service) StartAsJoiner(fsm raft.FSM, ip string, port int, clusterKey st
_, err = s.SetupRaft(false, fsm)
if err != nil {
c.RaftIP = ""
c.RaftPort = 0
c.RaftPort = ClusterRaftPort
c.Enabled = false
c.Key = ""
@@ -539,7 +539,7 @@ func (s *Service) ClearClusteredData() error {
})
}
func (s *Service) AcceptJoin(nodeID, nodeIp string, nodePort int, providedKey string) error {
func (s *Service) AcceptJoin(nodeID, nodeIp string, providedKey string) error {
details, err := s.GetClusterDetails()
if err != nil {
return err
@@ -569,7 +569,7 @@ func (s *Service) AcceptJoin(nodeID, nodeIp string, nodePort int, providedKey st
conf := fut.Configuration()
sid := raft.ServerID(nodeID)
saddr := raft.ServerAddress(fmt.Sprintf("%s:%d", nodeIp, nodePort))
saddr := raft.ServerAddress(RaftServerAddress(nodeIp))
for _, srv := range conf.Servers {
if srv.ID == sid {
@@ -623,7 +623,7 @@ func (s *Service) MarkDeclustered() error {
c.Key = ""
c.RaftBootstrap = nil
c.RaftIP = ""
c.RaftPort = 0
c.RaftPort = ClusterRaftPort
if err := s.DB.Save(&c).Error; err != nil {
return err
+45
View File
@@ -0,0 +1,45 @@
// SPDX-License-Identifier: BSD-2-Clause
//
// Copyright (c) 2025 The FreeBSD Foundation.
//
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
// under sponsorship from the FreeBSD Foundation.
package cluster
import (
"fmt"
clusterModels "github.com/alchemillahq/sylve/internal/db/models/cluster"
"gorm.io/gorm"
)
const legacyClusterEmbeddedSSHPort = 8122
func (s *Service) MigrateLegacyPorts() error {
if s == nil || s.DB == nil {
return fmt.Errorf("cluster_service_unavailable")
}
return s.DB.Transaction(func(tx *gorm.DB) error {
var c clusterModels.Cluster
if err := tx.First(&c).Error; err != nil {
return fmt.Errorf("failed_to_load_cluster_record: %w", err)
}
if c.RaftPort != ClusterRaftPort {
if err := tx.Model(&c).Update("raft_port", ClusterRaftPort).Error; err != nil {
return fmt.Errorf("failed_to_migrate_raft_port: %w", err)
}
}
if err := tx.Model(&clusterModels.ClusterSSHIdentity{}).
Where("ssh_port IN ?", []int{0, legacyClusterEmbeddedSSHPort}).
Update("ssh_port", ClusterEmbeddedSSHPort).Error; err != nil {
return fmt.Errorf("failed_to_migrate_cluster_ssh_identity_ports: %w", err)
}
return nil
})
}
+100
View File
@@ -0,0 +1,100 @@
// SPDX-License-Identifier: BSD-2-Clause
//
// Copyright (c) 2025 The FreeBSD Foundation.
//
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
// under sponsorship from the FreeBSD Foundation.
package cluster
import (
"strings"
"testing"
clusterModels "github.com/alchemillahq/sylve/internal/db/models/cluster"
)
func TestMigrateLegacyPortsRewritesRaftAndSSHPorts(t *testing.T) {
db := newClusterServiceTestDB(t, &clusterModels.Cluster{}, &clusterModels.ClusterSSHIdentity{})
s := &Service{DB: db}
clusterRecord := clusterModels.Cluster{
Enabled: false,
Key: "",
RaftIP: "",
RaftPort: 7000,
}
if err := db.Create(&clusterRecord).Error; err != nil {
t.Fatalf("failed to seed cluster record: %v", err)
}
identities := []clusterModels.ClusterSSHIdentity{
{NodeUUID: "node-a", SSHUser: "root", SSHHost: "10.0.0.10", SSHPort: 0, PublicKey: "pk-a"},
{NodeUUID: "node-b", SSHUser: "root", SSHHost: "10.0.0.11", SSHPort: legacyClusterEmbeddedSSHPort, PublicKey: "pk-b"},
{NodeUUID: "node-c", SSHUser: "root", SSHHost: "10.0.0.12", SSHPort: 2222, PublicKey: "pk-c"},
}
if err := db.Create(&identities).Error; err != nil {
t.Fatalf("failed to seed cluster ssh identities: %v", err)
}
if err := s.MigrateLegacyPorts(); err != nil {
t.Fatalf("MigrateLegacyPorts returned error: %v", err)
}
var updatedCluster clusterModels.Cluster
if err := db.First(&updatedCluster, clusterRecord.ID).Error; err != nil {
t.Fatalf("failed to fetch migrated cluster record: %v", err)
}
if updatedCluster.RaftPort != ClusterRaftPort {
t.Fatalf("expected raft port %d, got %d", ClusterRaftPort, updatedCluster.RaftPort)
}
var migrated []clusterModels.ClusterSSHIdentity
if err := db.Order("node_uuid ASC").Find(&migrated).Error; err != nil {
t.Fatalf("failed to fetch migrated cluster ssh identities: %v", err)
}
if len(migrated) != 3 {
t.Fatalf("expected 3 migrated identities, got %d", len(migrated))
}
for _, identity := range migrated {
switch identity.NodeUUID {
case "node-a", "node-b":
if identity.SSHPort != ClusterEmbeddedSSHPort {
t.Fatalf("expected %s SSH port to be migrated to %d, got %d", identity.NodeUUID, ClusterEmbeddedSSHPort, identity.SSHPort)
}
case "node-c":
if identity.SSHPort != 2222 {
t.Fatalf("expected non-legacy SSH port to remain 2222, got %d", identity.SSHPort)
}
default:
t.Fatalf("unexpected node UUID in migrated identities: %s", identity.NodeUUID)
}
}
}
func TestMigrateLegacyPortsFailsWhenClusterRecordMissing(t *testing.T) {
db := newClusterServiceTestDB(t, &clusterModels.Cluster{}, &clusterModels.ClusterSSHIdentity{})
s := &Service{DB: db}
err := s.MigrateLegacyPorts()
if err == nil {
t.Fatal("expected migration error when cluster record is missing, got nil")
}
if !strings.Contains(err.Error(), "failed_to_load_cluster_record") {
t.Fatalf("expected failed_to_load_cluster_record error, got: %v", err)
}
}
func TestMigrateLegacyPortsFailsWhenServiceUnavailable(t *testing.T) {
s := &Service{}
err := s.MigrateLegacyPorts()
if err == nil {
t.Fatal("expected service unavailable error, got nil")
}
if !strings.Contains(err.Error(), "cluster_service_unavailable") {
t.Fatalf("expected cluster_service_unavailable error, got: %v", err)
}
}
+18 -3
View File
@@ -8,7 +8,22 @@
package cluster
const (
ClusterEmbeddedSSHPort = 8122
ClusterEmbeddedHTTPSPort = 8124
import (
"net"
"strconv"
"strings"
)
const (
ClusterRaftPort = 8180
ClusterEmbeddedSSHPort = 8183
ClusterEmbeddedHTTPSPort = 8184
)
func ClusterAPIHost(ip string) string {
return net.JoinHostPort(strings.TrimSpace(ip), strconv.Itoa(ClusterEmbeddedHTTPSPort))
}
func RaftServerAddress(ip string) string {
return net.JoinHostPort(strings.TrimSpace(ip), strconv.Itoa(ClusterRaftPort))
}
+69
View File
@@ -0,0 +1,69 @@
// SPDX-License-Identifier: BSD-2-Clause
//
// Copyright (c) 2025 The FreeBSD Foundation.
//
// This software was developed by Hayzam Sherif <hayzam@alchemilla.io>
// of Alchemilla Ventures Pvt. Ltd. <hello@alchemilla.io>,
// under sponsorship from the FreeBSD Foundation.
package cluster
import (
"net"
"strconv"
"testing"
)
func TestRaftServerAddressUsesFixedPort(t *testing.T) {
tests := []struct {
name string
ip string
}{
{name: "ipv4", ip: "10.20.30.40"},
{name: "ipv6", ip: "::1"},
{name: "trimmed", ip: " 192.168.1.50 "},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
addr := RaftServerAddress(tt.ip)
host, port, err := net.SplitHostPort(addr)
if err != nil {
t.Fatalf("SplitHostPort failed for %q: %v", addr, err)
}
if port != strconv.Itoa(ClusterRaftPort) {
t.Fatalf("expected raft port %d, got %s", ClusterRaftPort, port)
}
if host == "" {
t.Fatal("expected non-empty host")
}
})
}
}
func TestClusterAPIHostUsesFixedHTTPSPort(t *testing.T) {
tests := []struct {
name string
ip string
}{
{name: "ipv4", ip: "10.20.30.41"},
{name: "ipv6", ip: "fd00::abcd"},
{name: "trimmed", ip: " 192.168.1.51 "},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
hostPort := ClusterAPIHost(tt.ip)
host, port, err := net.SplitHostPort(hostPort)
if err != nil {
t.Fatalf("SplitHostPort failed for %q: %v", hostPort, err)
}
if port != strconv.Itoa(ClusterEmbeddedHTTPSPort) {
t.Fatalf("expected cluster API port %d, got %s", ClusterEmbeddedHTTPSPort, port)
}
if host == "" {
t.Fatal("expected non-empty host")
}
})
}
}
+5 -3
View File
@@ -49,7 +49,9 @@ func (s *Service) SetupRaft(bootstrap bool, fsm raft.FSM) (*raft.Raft, error) {
return nil, fmt.Errorf("failed_to_get_cluster_info: %v", err)
}
err := network.TryBindToPort(c.RaftIP, c.RaftPort, "tcp")
port := ClusterRaftPort
err := network.TryBindToPort(c.RaftIP, port, "tcp")
if err != nil {
return nil, fmt.Errorf("failed_to_bind_raft_port: %v", err)
}
@@ -84,7 +86,7 @@ func (s *Service) SetupRaft(bootstrap bool, fsm raft.FSM) (*raft.Raft, error) {
return nil, fmt.Errorf("failed_to_create_snap_store")
}
bindAddr := fmt.Sprintf("%s:%d", c.RaftIP, c.RaftPort)
bindAddr := RaftServerAddress(c.RaftIP)
tcpAddr, err := net.ResolveTCPAddr("tcp", bindAddr)
if err != nil {
return nil, fmt.Errorf("Could not resolve address: %s", err)
@@ -252,7 +254,7 @@ func (s *Service) ResetRaftNode() error {
}
err = utils.HTTPPostJSON(
fmt.Sprintf("https://%s:%d/api/cluster/remove-peer", host, ClusterEmbeddedHTTPSPort), payload, headers)
fmt.Sprintf("https://%s/api/cluster/remove-peer", ClusterAPIHost(host)), payload, headers)
if err != nil {
return fmt.Errorf("failed_to_remove_peer_from_leader: %v", err)
+4 -7
View File
@@ -14,25 +14,22 @@ export async function getDetails(): Promise<ClusterDetails> {
return await apiRequest('/cluster', ClusterDetailsSchema, 'GET');
}
export async function createCluster(ip: string, port: number): Promise<APIResponse> {
export async function createCluster(ip: string): Promise<APIResponse> {
return await apiRequest('/cluster', APIResponseSchema, 'POST', {
ip: ip,
port: port
ip: ip
});
}
export async function joinCluster(
nodeId: string,
nodeIp: string,
nodePort: number,
leaderApi: string,
leaderIp: string,
clusterKey: string
): Promise<APIResponse> {
return await apiRequest('/cluster/join', APIResponseSchema, 'POST', {
nodeId: nodeId,
nodeIp: nodeIp,
nodePort: nodePort,
leaderApi: leaderApi,
leaderIp: leaderIp,
clusterKey: clusterKey
});
}
@@ -5,8 +5,7 @@
import * as Dialog from '$lib/components/ui/dialog/index.js';
import { storage } from '$lib';
import { handleAPIError } from '$lib/utils/http';
import { isValidIPv4, isValidIPv6, isValidPortNumber } from '$lib/utils/string';
import { onMount } from 'svelte';
import { isValidIPv4, isValidIPv6 } from '$lib/utils/string';
import { toast } from 'svelte-sonner';
import { logOut } from '$lib/api/auth';
@@ -17,8 +16,7 @@
let { open = $bindable(), reload = $bindable() }: Props = $props();
let options = {
ip: '',
port: 8180
ip: ''
};
let properties = $state(options);
@@ -39,8 +37,6 @@
if (!isValidIPv4(properties.ip) && !isValidIPv6(properties.ip)) {
error = 'Invalid IP address';
} else if (!isValidPortNumber(properties.port)) {
error = 'Invalid port number';
}
if (error) {
@@ -53,7 +49,7 @@
loading = true;
const response = await createCluster(properties.ip, properties.port);
const response = await createCluster(properties.ip);
reload = true;
loading = false;
if (response.error) {
@@ -123,13 +119,6 @@
classes="flex-1 space-y-1.5"
/>
<CustomValueInput
bind:value={properties.port}
placeholder="Node Port"
classes="flex-1 space-y-1.5"
type="number"
/>
<Dialog.Footer class="flex justify-end">
<div class="flex w-full items-center justify-end gap-2">
<Button onclick={create} type="submit" size="sm" disabled={loading}>
@@ -5,7 +5,7 @@
import * as Dialog from '$lib/components/ui/dialog/index.js';
import Input from '$lib/components/ui/input/input.svelte';
import { handleAPIError } from '$lib/utils/http';
import { isValidIPv4, isValidIPv6, isValidPortNumber } from '$lib/utils/string';
import { isValidIPv4, isValidIPv6 } from '$lib/utils/string';
import { toast } from 'svelte-sonner';
import { storage } from '$lib';
@@ -20,9 +20,8 @@
isValidIPv4(window.location.hostname) || isValidIPv6(window.location.hostname)
? window.location.hostname
: '',
port: 8180,
clusterKey: '',
leaderApi: ''
leaderIp: ''
};
let properties = $state(options);
@@ -33,12 +32,10 @@
if (!isValidIPv4(properties.ip) && !isValidIPv6(properties.ip)) {
error = 'Invalid IP address';
} else if (!isValidPortNumber(properties.port)) {
error = 'Invalid port number';
}
if (!properties.leaderApi) {
error = 'Leader API is required';
if (!isValidIPv4(properties.leaderIp) && !isValidIPv6(properties.leaderIp)) {
error = 'Leader IP is required';
} else if (!properties.clusterKey) {
error = 'Cluster Key is required';
}
@@ -57,8 +54,7 @@
const response = await joinCluster(
storage.nodeId,
properties.ip,
Number(properties.port),
properties.leaderApi,
properties.leaderIp,
properties.clusterKey
);
@@ -139,13 +135,6 @@
placeholder="Node IP"
classes="flex-1 space-y-1.5"
/>
<CustomValueInput
bind:value={properties.port}
placeholder="Node Port"
classes="flex-1 space-y-1.5"
type="number"
/>
</div>
<div class="flex flex-row gap-2">
@@ -153,8 +142,8 @@
<input type="password" style="display:none" autocomplete="new-password" />
<CustomValueInput
bind:value={properties.leaderApi}
placeholder="Leader API (192.168.1.1:8181)"
bind:value={properties.leaderIp}
placeholder="Leader IP (192.168.1.1)"
classes="flex-1 space-y-1.5 w-1/2"
/>
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff