Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion api/api/versions.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
{
"version": "v1",
"status": "active",
"release_date": "2025-10-29T22:13:12.29304044+05:30",
"release_date": "2025-10-31T19:02:08.937503+05:30",
"end_of_life": "0001-01-01T00:00:00Z",
"changes": [
"Initial API version"
Expand Down
2 changes: 1 addition & 1 deletion api/doc/openapi.json

Large diffs are not rendered by default.

66 changes: 48 additions & 18 deletions api/internal/features/deploy/docker/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"context"
"fmt"
"io"
"net"

"github.com/docker/docker/api/types"
"github.com/docker/docker/api/types/container"
Expand All @@ -20,9 +21,17 @@ import (
)

type DockerService struct {
Cli *client.Client
Ctx context.Context
logger logger.Logger
Cli *client.Client
Ctx context.Context
logger logger.Logger
sshTunnel *SSHTunnel
}

type SSHTunnel struct {
localSocket string
sshClient *ssh.SSH
listener net.Listener
cleanup func() error
}

type DockerRepository interface {
Expand Down Expand Up @@ -79,27 +88,48 @@ type DockerClient struct {

// NewDockerService creates a new instance of DockerService using the default docker client.
func NewDockerService() *DockerService {
client := NewDockerClient()
service := &DockerService{
Cli: client,
Ctx: context.Background(),
logger: logger.NewLogger(),
}
lgr := logger.NewLogger()
cli, tunnel := newDockerClientWithOptionalSSHTunnel(lgr)
svc := &DockerService{Cli: cli, Ctx: context.Background(), logger: lgr, sshTunnel: tunnel}

// Initialize cluster if not already initialized, this should be run on master node only
// TODO: Add a check to see if the node is the master node
// WARNING: This should be thought again during multi-server architecture feature
if !isClusterInitialized(client) {
if err := service.InitCluster(); err != nil {
service.logger.Log(logger.Warning, "Failed to initialize cluster", err.Error())
if !isClusterInitialized(svc.Cli) {
if err := svc.InitCluster(); err != nil {
svc.logger.Log(logger.Warning, "Failed to initialize cluster", err.Error())
} else {
service.logger.Log(logger.Info, "Cluster initialized successfully", "")
svc.logger.Log(logger.Info, "Cluster initialized successfully", "")
}
} else {
service.logger.Log(logger.Info, "Cluster already initialized", "")
svc.logger.Log(logger.Info, "Cluster already initialized", "")
}

return svc
}

func newDockerClientWithOptionalSSHTunnel(lgr logger.Logger) (*client.Client, *SSHTunnel) {
sshClient := ssh.NewSSH()
// Try to create an SSH tunnel to the remote Docker daemon socket, if it fails, use the local docker socket
tunnel, err := CreateSSHTunnel(sshClient, lgr)
if err != nil || tunnel == nil {
if err != nil {
lgr.Log(logger.Info, "SSH tunnel not established, using local docker socket", err.Error())
}
// If the SSH tunnel creation fails, use the local docker socket
lgr.Log(logger.Info, "Using local docker socket", "")
return NewDockerClient(), nil
}

return service
host := fmt.Sprintf("unix://%s", tunnel.localSocket)
lgr.Log(logger.Info, "SSH tunnel established; using tunneled docker socket", host)
cli, cliErr := client.NewClientWithOpts(
client.WithHost(host),
client.WithAPIVersionNegotiation(),
)
if cliErr != nil {
lgr.Log(logger.Warning, "Failed to create docker client over SSH tunnel, using local", cliErr.Error())
return NewDockerClient(), nil
}
lgr.Log(logger.Info, "Docker client created over SSH tunnel", "")
return cli, tunnel
}

func isClusterInitialized(cli *client.Client) bool {
Expand Down
98 changes: 98 additions & 0 deletions api/internal/features/deploy/docker/ssh_tunnel.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
package docker

import (
"fmt"
"io"
"net"
"os"
"path/filepath"
"time"

"github.com/raghavyuva/nixopus-api/internal/features/logger"
"github.com/raghavyuva/nixopus-api/internal/features/ssh"
)

// CreateSSHTunnel creates a local Unix socket and forwards all connections
// through the provided SSH client to the remote Docker daemon socket
// at /var/run/docker.sock. It returns an SSHTunnel with a cleanup function
// that closes the listener and removes the temporary socket file.
func CreateSSHTunnel(sshClient *ssh.SSH, logger logger.Logger) (*SSHTunnel, error) {
tempDir := os.TempDir()
localSocket := filepath.Join(tempDir, fmt.Sprintf("docker-ssh-%d.sock", time.Now().UnixNano()))

os.Remove(localSocket)

listener, err := net.Listen("unix", localSocket)
if err != nil {
return nil, fmt.Errorf("failed to create local socket: %w", err)
}

tunnel := &SSHTunnel{
localSocket: localSocket,
sshClient: sshClient,
listener: listener,
cleanup: func() error {
listener.Close()
os.Remove(localSocket)
return nil
},
}

go tunnel.handleConnections(logger)

return tunnel, nil
}

// handleConnections manages the SSH tunnel connections
func (t *SSHTunnel) handleConnections(lgr logger.Logger) {
for {
localConn, err := t.listener.Accept()
if err != nil {
lgr.Log(logger.Error, "SSH tunnel listener error", err.Error())
return
}

go t.forwardConnection(localConn, lgr)
}
}

// forwardConnection forwards a local connection through the SSH tunnel to the remote Docker socket
func (t *SSHTunnel) forwardConnection(localConn net.Conn, lgr logger.Logger) {
defer localConn.Close()

sshConn, err := t.sshClient.Connect()
if err != nil {
lgr.Log(logger.Error, "Failed to establish SSH connection", err.Error())
return
}
defer sshConn.Close()

remoteConn, err := sshConn.Dial("unix", "/var/run/docker.sock")
if err != nil {
lgr.Log(logger.Error, "Failed to connect to remote Docker socket", err.Error())
return
}
defer remoteConn.Close()

done := make(chan struct{}, 2)

go func() {
io.Copy(remoteConn, localConn)
done <- struct{}{}
}()

go func() {
io.Copy(localConn, remoteConn)
done <- struct{}{}
}()

<-done
}

// Close cleans up the DockerService and any SSH tunnels
func (s *DockerService) Close() error {
if s.sshTunnel != nil {
return s.sshTunnel.cleanup()
}
return nil
}
2 changes: 1 addition & 1 deletion api/internal/features/feature-flags/service/init.go
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ func (s *FeatureFlagService) GetFeatureFlags(organizationID uuid.UUID) ([]types.
defer tx.Rollback()

txStorage := s.storage.WithTx(tx)
defaultFeatures := []types.FeatureName{types.FeatureDomain, types.FeatureTerminal, types.FeatureNotifications, types.FeatureFileManager, types.FeatureSelfHosted, types.FeatureAudit, types.FeatureGithubConnector, types.FeatureMonitoring, types.FeatureContainer}
defaultFeatures := []types.FeatureName{types.FeatureDomain, types.FeatureTerminal, types.FeatureNotifications, types.FeatureFileManager, types.FeatureSelfHosted, types.FeatureAudit, types.FeatureGithubConnector, types.FeatureMonitoring, types.FeatureContainer, types.FeatureServers}
defaultFlags := make([]types.FeatureFlag, 0, len(defaultFeatures))

for _, feature := range defaultFeatures {
Expand Down
88 changes: 88 additions & 0 deletions api/internal/features/servers/controller/create-server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,88 @@
package controller

import (
"net/http"

"github.com/go-fuego/fuego"
"github.com/google/uuid"
"github.com/raghavyuva/nixopus-api/internal/features/logger"
"github.com/raghavyuva/nixopus-api/internal/features/servers/types"
"github.com/raghavyuva/nixopus-api/internal/utils"

shared_types "github.com/raghavyuva/nixopus-api/internal/types"
)

func (c *ServersController) CreateServer(f fuego.ContextWithBody[types.CreateServerRequest]) (*shared_types.Response, error) {
serverRequest, err := f.Body()

if err != nil {
return nil, fuego.HTTPError{
Err: err,
Status: http.StatusBadRequest,
}
}

w, r := f.Response(), f.Request()
if !c.parseAndValidate(w, r, &serverRequest) {
return nil, fuego.HTTPError{
Err: nil,
Status: http.StatusBadRequest,
}
}

user := utils.GetUser(w, r)

if user == nil {
return nil, fuego.HTTPError{
Err: nil,
Status: http.StatusUnauthorized,
}
}

organization := utils.GetOrganizationID(r)

if organization == uuid.Nil {
return nil, fuego.HTTPError{
Err: nil,
Status: http.StatusUnauthorized,
}
}

created, err := c.service.CreateServer(serverRequest, user.ID.String(), organization.String())

if err != nil {
c.logger.Log(logger.Error, err.Error(), "")

if isInvalidServerError(err) {
return nil, fuego.HTTPError{
Err: err,
Status: http.StatusBadRequest,
}
}

if err == types.ErrServerAlreadyExists || err == types.ErrServerHostAlreadyExists {
return nil, fuego.HTTPError{
Err: err,
Status: http.StatusConflict,
}
}

if isPermissionError(err) {
return nil, fuego.HTTPError{
Err: err,
Status: http.StatusForbidden,
}
}

return nil, fuego.HTTPError{
Err: err,
Status: http.StatusInternalServerError,
}
}

return &shared_types.Response{
Status: "success",
Message: "Server created successfully",
Data: created,
}, nil
}
78 changes: 78 additions & 0 deletions api/internal/features/servers/controller/delete-server.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package controller

import (
"net/http"

"github.com/go-fuego/fuego"
"github.com/raghavyuva/nixopus-api/internal/features/logger"
"github.com/raghavyuva/nixopus-api/internal/features/servers/types"
"github.com/raghavyuva/nixopus-api/internal/utils"

shared_types "github.com/raghavyuva/nixopus-api/internal/types"
)

func (c *ServersController) DeleteServer(f fuego.ContextWithBody[types.DeleteServerRequest]) (*shared_types.Response, error) {
serverRequest, err := f.Body()

if err != nil {
return nil, fuego.HTTPError{
Err: err,
Status: http.StatusBadRequest,
}
}

w, r := f.Response(), f.Request()
if !c.parseAndValidate(w, r, &serverRequest) {
return nil, fuego.HTTPError{
Err: nil,
Status: http.StatusBadRequest,
}
}

user := utils.GetUser(w, r)

if user == nil {
return nil, fuego.HTTPError{
Err: nil,
Status: http.StatusUnauthorized,
}
}

err = c.service.DeleteServer(serverRequest, user.ID.String())

if err != nil {
c.logger.Log(logger.Error, err.Error(), "")

if isInvalidServerError(err) {
return nil, fuego.HTTPError{
Err: err,
Status: http.StatusBadRequest,
}
}

if err == types.ErrServerNotFound {
return nil, fuego.HTTPError{
Err: err,
Status: http.StatusNotFound,
}
}

if isPermissionError(err) {
return nil, fuego.HTTPError{
Err: err,
Status: http.StatusForbidden,
}
}

return nil, fuego.HTTPError{
Err: err,
Status: http.StatusInternalServerError,
}
}

return &shared_types.Response{
Status: "success",
Message: "Server deleted successfully",
Data: nil,
}, nil
}
Loading