Skip to content
Merged
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 cmd/dockify/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ func main() {
serverListAdapter := &serverLister{svc: svc}

webhookSecretFn := func() string { s, _ := settingsSvc.GetWebhookSecret(); return s }
router := httppkg.NewRouter(svc, appSvc, httppkg.Render, serverListAdapter, cfg.AdminUser, cfg.AdminPass, cfg.SSHKeyDir, webhookSecretFn, settingsHandler, backupHandler, version)
router := httppkg.NewRouter(svc, appSvc, httppkg.Render, serverListAdapter, cfg.AdminUser, cfg.AdminPass, cfg.SSHKeyDir, webhookSecretFn, settingsHandler, backupHandler, version, cfg.DevMock)

if cfg.AdminPass == "" {
log.Println("WARNING: No admin password set (DOCKIFY_ADMIN_PASSWORD). Web UI has no authentication.")
Expand Down
117 changes: 70 additions & 47 deletions internal/app/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -190,38 +190,7 @@ func (s *Service) deployWithCommit(id int64, commitSHA string) {
}

if app.Domain != "" {
target := fmt.Sprintf("%s:%d", getServiceName(composeContent), app.Port)
caddyClient := caddy.NewClient(client)
var caddyErr error
if app.AuthUser != "" && app.AuthPass != "" {
caddyErr = caddyClient.AddRouteWithAuth(app.Domain, target, app.AuthUser, app.AuthPass)
log.Printf("Caddy route (with auth) added: %s -> %s", app.Domain, target)
} else {
caddyErr = caddyClient.AddRoute(app.Domain, target)
log.Printf("Caddy route added: %s -> %s", app.Domain, target)
}
if caddyErr != nil {
log.Printf("Warning: caddy route injection failed for %q: %v", app.Name, caddyErr)
logs = append(logs, fmt.Sprintf("caddy: %v", caddyErr))
}
s.repo.SaveRoute(&Route{
AppID: id,
ServerID: svr.ID,
Domain: app.Domain,
Target: target,
})

if s.cf != nil && s.cf.Enabled() {
s.repo.DeleteDNSRecords(id)
record, err := s.cf.UpsertRecord(app.Domain, svr.Host, false)
if err != nil {
log.Printf("Warning: Cloudflare DNS failed for %q: %v", app.Name, err)
logs = append(logs, fmt.Sprintf("dns: %v", err))
} else {
log.Printf("DNS record upserted: %s -> %s", record.Name, record.Content)
s.repo.SaveDNSRecord(id, svr.ID, record.ZoneID, record.ID, record.Name, "A", record.Content, record.Proxied)
}
}
s.setupRouteAndDNS(app, svr, client, composeContent, &logs)
} else {
log.Printf("App %q deployed as internal service (no domain, no Caddy route)", app.Name)
}
Expand Down Expand Up @@ -288,22 +257,9 @@ func (s *Service) Undeploy(id int64) error {
return fmt.Errorf("app not found")
}

if s.cf != nil && s.cf.Enabled() {
dnsRecords, err := s.repo.GetDNSRecords(id)
if err == nil {
for _, rec := range dnsRecords {
if delErr := s.cf.DeleteRecord(rec.RecordID); delErr != nil {
log.Printf("Warning: failed to delete DNS record %s: %v", rec.RecordID, delErr)
}
s.repo.DeleteDNSRecord(rec.ID)
}
}
}

svr, err := s.serverRepo.Get(app.ServerID)
if err != nil || svr == nil {
log.Printf("Undeploy %q: server not found, cleaning up DB", app.Name)
s.repo.DeleteDNSRecords(app.ID)
s.repo.DeleteRoutes(app.ID)
s.repo.DeleteDeployments(app.ID)
s.repo.Delete(id)
Expand All @@ -313,7 +269,6 @@ func (s *Service) Undeploy(id int64) error {
client, err := s.connFactory(svr.Host, svr.Port, svr.User, svr.SSHKey)
if err != nil {
log.Printf("Undeploy %q: SSH connect failed, cleaning up DB: %v", app.Name, err)
s.repo.DeleteDNSRecords(app.ID)
s.repo.DeleteRoutes(app.ID)
s.repo.DeleteDeployments(app.ID)
s.repo.Delete(id)
Expand All @@ -336,7 +291,6 @@ func (s *Service) Undeploy(id int64) error {
}

s.repo.DeleteRoutes(app.ID)
s.repo.DeleteDNSRecords(app.ID)
s.repo.DeleteDeployments(app.ID)

client.Exec(fmt.Sprintf("rm -rf %s", remoteDir))
Expand All @@ -346,6 +300,59 @@ func (s *Service) Undeploy(id int64) error {
return nil
}

func (s *Service) setupRouteAndDNS(app *App, svr *server.Server, client ssh.Connector, composeContent string, logs *[]string) {
target := fmt.Sprintf("%s:%d", getServiceName(composeContent), app.Port)
caddyClient := caddy.NewClient(client)
var caddyErr error
if app.AuthUser != "" && app.AuthPass != "" {
caddyErr = caddyClient.AddRouteWithAuth(app.Domain, target, app.AuthUser, app.AuthPass)
log.Printf("Caddy route (with auth) added: %s -> %s", app.Domain, target)
} else {
caddyErr = caddyClient.AddRoute(app.Domain, target)
log.Printf("Caddy route added: %s -> %s", app.Domain, target)
}
if caddyErr != nil {
log.Printf("Warning: caddy route injection failed for %q: %v", app.Name, caddyErr)
if logs != nil {
*logs = append(*logs, fmt.Sprintf("caddy: %v", caddyErr))
}
}
s.repo.SaveRoute(&Route{
AppID: app.ID,
ServerID: svr.ID,
Domain: app.Domain,
Target: target,
})

if s.cf != nil && s.cf.Enabled() {
records, err := s.cf.ListRecords(app.Domain)
if err != nil {
log.Printf("Warning: Cloudflare DNS lookup failed for %q: %v", app.Name, err)
return
}
exists := false
for _, r := range records {
if r.Name == app.Domain && r.Type == "A" {
exists = true
log.Printf("DNS A record already exists for %s, skipping (IP: %s, proxied: %v)", app.Domain, r.Content, r.Proxied)
break
}
}
if !exists {
record, err := s.cf.CreateRecord(app.Domain, svr.Host, false)
if err != nil {
log.Printf("Warning: Cloudflare DNS failed for %q: %v", app.Name, err)
if logs != nil {
*logs = append(*logs, fmt.Sprintf("dns: %v", err))
}
} else {
log.Printf("DNS A record created: %s -> %s", record.Name, record.Content)
s.repo.SaveDNSRecord(app.ID, svr.ID, record.ZoneID, record.ID, record.Name, "A", record.Content, record.Proxied)
}
}
}
}

func (s *Service) Redeploy(id int64) {
app, err := s.repo.Get(id)
if err != nil || app == nil {
Expand Down Expand Up @@ -376,6 +383,13 @@ func (s *Service) Stop(id int64) error {
composePath := fmt.Sprintf("/opt/dockify/apps/%s/docker-compose.yml", app.Name)
log.Printf("Stopping %q on %s...", app.Name, svr.Name)

if app.Domain != "" {
caddyClient := caddy.NewClient(client)
if err := caddyClient.RemoveRoute(app.Domain); err != nil {
log.Printf("Warning: failed to remove Caddy route for %q: %v", app.Name, err)
}
}

if out, err := client.Exec(fmt.Sprintf("%s -f %s stop 2>&1", dc, composePath)); err != nil {
return fmt.Errorf("compose stop: %w\n%s", err, out)
}
Expand Down Expand Up @@ -410,6 +424,15 @@ func (s *Service) Start(id int64) error {
return fmt.Errorf("compose start: %w\n%s", err, out)
}

if app.Domain != "" {
composeRemote, err := client.Exec(fmt.Sprintf("cat %s", composePath))
if err == nil {
s.setupRouteAndDNS(app, svr, client, composeRemote, nil)
} else {
log.Printf("Warning: could not read remote compose for %q: %v", app.Name, err)
}
}

s.repo.UpdateStatus(id, StatusRunning)
log.Printf("App %q started", app.Name)
return nil
Expand Down
14 changes: 13 additions & 1 deletion internal/http/router.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,14 +14,15 @@ import (
chimw "github.com/go-chi/chi/v5/middleware"
)

func NewRouter(svc *server.Service, appSvc *app.Service, render RenderFunc, serverListAdapter app.ServerRepo, cfgUser, cfgPass, sshKeyDir string, webhookSecretFn func() string, settingsHandler *settings.Handler, backupHandler *backup.Handler, version string) *chi.Mux {
func NewRouter(svc *server.Service, appSvc *app.Service, render RenderFunc, serverListAdapter app.ServerRepo, cfgUser, cfgPass, sshKeyDir string, webhookSecretFn func() string, settingsHandler *settings.Handler, backupHandler *backup.Handler, version string, devMock bool) *chi.Mux {
r := chi.NewRouter()

r.Use(chimw.Logger)
r.Use(chimw.Recoverer)
r.Use(chimw.RealIP)
r.Use(PrefixMiddleware)
r.Use(CORSMiddleware)
r.Use(DevMockMiddleware(devMock))

authEnabled := cfgPass != ""

Expand Down Expand Up @@ -240,3 +241,14 @@ func CORSMiddleware(next http.Handler) http.Handler {
next.ServeHTTP(w, r)
})
}

func DevMockMiddleware(devMock bool) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if devMock {
r = SetDevMock(r, true)
}
next.ServeHTTP(w, r)
})
}
}
12 changes: 12 additions & 0 deletions internal/http/templates.go
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,7 @@ var tmpl = template.Must(template.New("").Funcs(funcMap).ParseFS(templateFS, "te
type contextKey string

const basePathKey contextKey = "basePath"
const devMockKey contextKey = "devMock"

func SetBasePath(r *http.Request, path string) *http.Request {
ctx := context.WithValue(r.Context(), basePathKey, path)
Expand All @@ -81,6 +82,16 @@ func GetBasePath(r *http.Request) string {
return "/"
}

func SetDevMock(r *http.Request, val bool) *http.Request {
ctx := context.WithValue(r.Context(), devMockKey, val)
return r.WithContext(ctx)
}

func GetDevMock(r *http.Request) bool {
v, _ := r.Context().Value(devMockKey).(bool)
return v
}

type RenderFunc func(w http.ResponseWriter, r *http.Request, status int, name string, data interface{})

func Render(w http.ResponseWriter, r *http.Request, status int, name string, data interface{}) {
Expand All @@ -94,6 +105,7 @@ func Render(w http.ResponseWriter, r *http.Request, status int, name string, dat
m, ok := data.(map[string]interface{})
if ok {
m["BasePath"] = basePath
m["DevMock"] = GetDevMock(r)
}

w.Header().Set("Content-Type", "text/html; charset=utf-8")
Expand Down
32 changes: 31 additions & 1 deletion internal/http/templates/layout.html
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,35 @@
.empty-state { text-align: center; padding: 3em 1em; color: var(--text-muted); }
.empty-state p { margin-bottom: 0.8em; }

@keyframes blink-pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.3; }
}
.dev-mock-bar {
background: var(--yellow);
color: #000;
text-align: center;
padding: 0.35em 1em;
font-size: 0.82em;
font-weight: 600;
animation: blink-pulse 1.5s ease-in-out infinite;
letter-spacing: 0.03em;
text-transform: uppercase;
}
.dev-mock-badge {
display: inline-block;
background: var(--yellow);
color: #000;
font-size: 0.72em;
font-weight: 700;
padding: 0.15em 0.5em;
border-radius: 3px;
animation: blink-pulse 1.5s ease-in-out infinite;
letter-spacing: 0.04em;
text-transform: uppercase;
margin-left: 0.5em;
}

@media (max-width: 600px) {
.container { padding: 0.8em 1em; }
.grid { grid-template-columns: 1fr; }
Expand All @@ -268,8 +297,9 @@
{{end}}

{{define "nav"}}
{{if .DevMock}}<div class="dev-mock-bar">⚠ Dev Mock Mode — All SSH operations are simulated</div>{{end}}
<div class="top-nav">
<a href="." class="logo">Dockify</a>
<a href="." class="logo">Dockify{{if .DevMock}}<span class="dev-mock-badge">MOCK</span>{{end}}</a>
<div class="nav-links">
<a href=".">Dashboard</a>
<a href="servers">Servers</a>
Expand Down
2 changes: 2 additions & 0 deletions internal/http/templates/servers_detail.html
Original file line number Diff line number Diff line change
Expand Up @@ -32,9 +32,11 @@ <h3>Connection</h3>
<div class="btn-group">
<a href="servers/{{.Server.ID}}/edit" class="btn btn-ghost">Edit</a>
<button class="btn btn-ghost" hx-get="servers/{{.Server.ID}}/resources" hx-target="#resources-card" hx-swap="outerHTML">Refresh Resources</button>
{{if and (ne .Server.Status "online") (ne .Server.Status "initializing")}}
<form method="POST" action="servers/{{.Server.ID}}/init" style="display:inline">
<button type="submit" class="btn btn-primary">Initialize Worker</button>
</form>
{{end}}
<form method="POST" action="servers/{{.Server.ID}}/delete" style="display:inline"
onsubmit="return confirm('Remove {{.Server.Name}}? This cannot be undone.')">
<button type="submit" class="btn btn-ghost btn-red">Remove Server</button>
Expand Down
2 changes: 2 additions & 0 deletions internal/server/handler.go
Original file line number Diff line number Diff line change
Expand Up @@ -403,6 +403,8 @@ func (h *WebHandler) ServerEditForm(w http.ResponseWriter, r *http.Request, rend
return
}

go h.service.TestConnection(id)

http.Redirect(w, r, "/servers/"+strconv.FormatInt(id, 10), http.StatusSeeOther)
}

Expand Down
18 changes: 12 additions & 6 deletions internal/server/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,8 +116,11 @@ func (s *Service) InitWorker(id int64) error {
if err != nil {
log.Printf("Warning: failed to create dockify network: %v", err)
}
log.Printf("Deploying Caddy on %s...", server.Name)
caddyRun := `mkdir -p /opt/dockify/caddy && cat > /opt/dockify/caddy/Caddyfile << 'CADDYEOF'

caddyRunning, _ := client.Exec("docker ps -q --filter name=^/caddy$")
if strings.TrimSpace(caddyRunning) == "" {
log.Printf("Deploying Caddy on %s...", server.Name)
caddyRun := `mkdir -p /opt/dockify/caddy && cat > /opt/dockify/caddy/Caddyfile << 'CADDYEOF'
:80, :443 {
}
CADDYEOF
Expand All @@ -132,10 +135,13 @@ docker run -d \
-v /opt/dockify/caddy/Caddyfile:/etc/caddy/Caddyfile:ro \
--restart unless-stopped \
caddy:latest`
_, err = client.Exec(caddyRun)
if err != nil {
s.repo.UpdateStatus(id, StatusError)
return fmt.Errorf("deploy caddy: %w", err)
_, err = client.Exec(caddyRun)
if err != nil {
s.repo.UpdateStatus(id, StatusError)
return fmt.Errorf("deploy caddy: %w", err)
}
} else {
log.Printf("Caddy already running on %s, skipping deploy", server.Name)
}

s.repo.UpdateStatus(id, StatusOnline)
Expand Down
Loading