454 lines
11 KiB
Go
454 lines
11 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"setec-manager/internal/db"
|
|
"setec-manager/internal/deploy"
|
|
"setec-manager/internal/nginx"
|
|
)
|
|
|
|
var validDomainRegex = regexp.MustCompile(`^([a-zA-Z0-9]([a-zA-Z0-9\-]{0,61}[a-zA-Z0-9])?\.)+[a-zA-Z]{2,}$`)
|
|
|
|
func isValidDomain(domain string) bool {
|
|
if len(domain) > 253 {
|
|
return false
|
|
}
|
|
if net.ParseIP(domain) != nil {
|
|
return true
|
|
}
|
|
return validDomainRegex.MatchString(domain)
|
|
}
|
|
|
|
func (h *Handler) SiteList(w http.ResponseWriter, r *http.Request) {
|
|
sites, err := h.DB.ListSites()
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
// Enrich with running status
|
|
type siteView struct {
|
|
db.Site
|
|
Running bool `json:"running"`
|
|
Status string `json:"status"`
|
|
}
|
|
|
|
var views []siteView
|
|
for _, s := range sites {
|
|
sv := siteView{Site: s}
|
|
if s.AppType != "static" && s.AppPort > 0 {
|
|
unitName := fmt.Sprintf("app-%s", s.Domain)
|
|
active, _ := deploy.IsActive(unitName)
|
|
sv.Running = active
|
|
if active {
|
|
sv.Status = "active"
|
|
} else {
|
|
sv.Status = "inactive"
|
|
}
|
|
} else {
|
|
sv.Status = "static"
|
|
sv.Running = s.Enabled
|
|
}
|
|
views = append(views, sv)
|
|
}
|
|
|
|
if acceptsJSON(r) {
|
|
writeJSON(w, http.StatusOK, views)
|
|
return
|
|
}
|
|
h.render(w, "sites.html", views)
|
|
}
|
|
|
|
func (h *Handler) SiteNewForm(w http.ResponseWriter, r *http.Request) {
|
|
h.render(w, "site_new.html", nil)
|
|
}
|
|
|
|
func (h *Handler) SiteCreate(w http.ResponseWriter, r *http.Request) {
|
|
var site db.Site
|
|
if err := json.NewDecoder(r.Body).Decode(&site); err != nil {
|
|
// Try form values
|
|
site.Domain = r.FormValue("domain")
|
|
site.Aliases = r.FormValue("aliases")
|
|
site.AppType = r.FormValue("app_type")
|
|
site.AppRoot = r.FormValue("app_root")
|
|
site.GitRepo = r.FormValue("git_repo")
|
|
site.GitBranch = r.FormValue("git_branch")
|
|
site.AppEntry = r.FormValue("app_entry")
|
|
}
|
|
|
|
if site.Domain == "" {
|
|
writeError(w, http.StatusBadRequest, "domain is required")
|
|
return
|
|
}
|
|
if !isValidDomain(site.Domain) {
|
|
writeError(w, http.StatusBadRequest, "invalid domain name")
|
|
return
|
|
}
|
|
if site.AppType == "" {
|
|
site.AppType = "static"
|
|
}
|
|
if site.AppRoot == "" {
|
|
site.AppRoot = filepath.Join(h.Config.Nginx.Webroot, site.Domain)
|
|
}
|
|
if site.GitBranch == "" {
|
|
site.GitBranch = "main"
|
|
}
|
|
site.Enabled = true
|
|
|
|
// Check for duplicate
|
|
existing, _ := h.DB.GetSiteByDomain(site.Domain)
|
|
if existing != nil {
|
|
writeError(w, http.StatusConflict, "domain already exists")
|
|
return
|
|
}
|
|
|
|
// Create directory
|
|
os.MkdirAll(site.AppRoot, 0755)
|
|
|
|
// Clone repo if provided
|
|
if site.GitRepo != "" {
|
|
depID, _ := h.DB.CreateDeployment(nil, "clone")
|
|
out, err := deploy.Clone(site.GitRepo, site.GitBranch, site.AppRoot)
|
|
if err != nil {
|
|
h.DB.FinishDeployment(depID, "failed", out)
|
|
writeError(w, http.StatusInternalServerError, "git clone failed: "+out)
|
|
return
|
|
}
|
|
h.DB.FinishDeployment(depID, "success", out)
|
|
}
|
|
|
|
// Save to DB
|
|
id, err := h.DB.CreateSite(&site)
|
|
if err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
site.ID = id
|
|
|
|
// Generate nginx config
|
|
if err := nginx.GenerateConfig(h.Config, &site); err != nil {
|
|
writeError(w, http.StatusInternalServerError, "nginx config: "+err.Error())
|
|
return
|
|
}
|
|
|
|
// Enable site
|
|
nginx.EnableSite(h.Config, site.Domain)
|
|
nginx.Reload()
|
|
|
|
// Generate systemd unit for non-static apps
|
|
if site.AppType != "static" && site.AppEntry != "" {
|
|
h.generateAppUnit(&site)
|
|
}
|
|
|
|
if acceptsJSON(r) {
|
|
writeJSON(w, http.StatusCreated, site)
|
|
return
|
|
}
|
|
http.Redirect(w, r, fmt.Sprintf("/sites/%d", id), http.StatusSeeOther)
|
|
}
|
|
|
|
func (h *Handler) SiteDetail(w http.ResponseWriter, r *http.Request) {
|
|
id, err := paramInt(r, "id")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid id")
|
|
return
|
|
}
|
|
|
|
site, err := h.DB.GetSite(id)
|
|
if err != nil || site == nil {
|
|
writeError(w, http.StatusNotFound, "site not found")
|
|
return
|
|
}
|
|
|
|
// Get deployment history
|
|
deps, _ := h.DB.ListDeployments(&id, 10)
|
|
|
|
data := map[string]interface{}{
|
|
"Site": site,
|
|
"Deployments": deps,
|
|
}
|
|
|
|
if acceptsJSON(r) {
|
|
writeJSON(w, http.StatusOK, data)
|
|
return
|
|
}
|
|
h.render(w, "site_detail.html", data)
|
|
}
|
|
|
|
func (h *Handler) SiteUpdate(w http.ResponseWriter, r *http.Request) {
|
|
id, err := paramInt(r, "id")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid id")
|
|
return
|
|
}
|
|
|
|
site, err := h.DB.GetSite(id)
|
|
if err != nil || site == nil {
|
|
writeError(w, http.StatusNotFound, "site not found")
|
|
return
|
|
}
|
|
|
|
var update db.Site
|
|
if err := json.NewDecoder(r.Body).Decode(&update); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid body")
|
|
return
|
|
}
|
|
|
|
// Apply updates
|
|
if update.Domain != "" {
|
|
site.Domain = update.Domain
|
|
}
|
|
site.Aliases = update.Aliases
|
|
if update.AppType != "" {
|
|
site.AppType = update.AppType
|
|
}
|
|
if update.AppPort > 0 {
|
|
site.AppPort = update.AppPort
|
|
}
|
|
site.AppEntry = update.AppEntry
|
|
site.GitRepo = update.GitRepo
|
|
site.GitBranch = update.GitBranch
|
|
site.SSLEnabled = update.SSLEnabled
|
|
site.Enabled = update.Enabled
|
|
|
|
if err := h.DB.UpdateSite(site); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
// Regenerate nginx config
|
|
nginx.GenerateConfig(h.Config, site)
|
|
nginx.Reload()
|
|
|
|
writeJSON(w, http.StatusOK, site)
|
|
}
|
|
|
|
func (h *Handler) SiteDelete(w http.ResponseWriter, r *http.Request) {
|
|
id, err := paramInt(r, "id")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid id")
|
|
return
|
|
}
|
|
|
|
site, err := h.DB.GetSite(id)
|
|
if err != nil || site == nil {
|
|
writeError(w, http.StatusNotFound, "site not found")
|
|
return
|
|
}
|
|
|
|
// Disable nginx
|
|
nginx.DisableSite(h.Config, site.Domain)
|
|
nginx.Reload()
|
|
|
|
// Stop, disable, and remove the systemd unit
|
|
unitName := fmt.Sprintf("app-%s", site.Domain)
|
|
deploy.RemoveUnit(unitName)
|
|
|
|
if err := h.DB.DeleteSite(id); err != nil {
|
|
writeError(w, http.StatusInternalServerError, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"})
|
|
}
|
|
|
|
func (h *Handler) SiteDeploy(w http.ResponseWriter, r *http.Request) {
|
|
id, err := paramInt(r, "id")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid id")
|
|
return
|
|
}
|
|
|
|
site, err := h.DB.GetSite(id)
|
|
if err != nil || site == nil {
|
|
writeError(w, http.StatusNotFound, "site not found")
|
|
return
|
|
}
|
|
|
|
depID, _ := h.DB.CreateDeployment(&id, "deploy")
|
|
|
|
var output strings.Builder
|
|
|
|
// Git pull
|
|
if site.GitRepo != "" {
|
|
out, err := deploy.Pull(site.AppRoot)
|
|
output.WriteString(out)
|
|
if err != nil {
|
|
h.DB.FinishDeployment(depID, "failed", output.String())
|
|
writeError(w, http.StatusInternalServerError, "git pull failed")
|
|
return
|
|
}
|
|
}
|
|
|
|
// Reinstall deps based on app type
|
|
switch site.AppType {
|
|
case "python", "autarch":
|
|
venvDir := filepath.Join(site.AppRoot, "venv")
|
|
reqFile := filepath.Join(site.AppRoot, "requirements.txt")
|
|
if _, err := os.Stat(reqFile); err == nil {
|
|
out, _ := deploy.InstallRequirements(venvDir, reqFile)
|
|
output.WriteString(out)
|
|
}
|
|
case "node":
|
|
out, _ := deploy.NpmInstall(site.AppRoot)
|
|
output.WriteString(out)
|
|
}
|
|
|
|
// Restart service
|
|
unitName := fmt.Sprintf("app-%s", site.Domain)
|
|
deploy.Restart(unitName)
|
|
|
|
h.DB.FinishDeployment(depID, "success", output.String())
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "deployed"})
|
|
}
|
|
|
|
func (h *Handler) SiteRestart(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := paramInt(r, "id")
|
|
site, _ := h.DB.GetSite(id)
|
|
if site == nil {
|
|
writeError(w, http.StatusNotFound, "site not found")
|
|
return
|
|
}
|
|
unitName := fmt.Sprintf("app-%s", site.Domain)
|
|
deploy.Restart(unitName)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "restarted"})
|
|
}
|
|
|
|
func (h *Handler) SiteStop(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := paramInt(r, "id")
|
|
site, _ := h.DB.GetSite(id)
|
|
if site == nil {
|
|
writeError(w, http.StatusNotFound, "site not found")
|
|
return
|
|
}
|
|
unitName := fmt.Sprintf("app-%s", site.Domain)
|
|
deploy.Stop(unitName)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "stopped"})
|
|
}
|
|
|
|
func (h *Handler) SiteStart(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := paramInt(r, "id")
|
|
site, _ := h.DB.GetSite(id)
|
|
if site == nil {
|
|
writeError(w, http.StatusNotFound, "site not found")
|
|
return
|
|
}
|
|
unitName := fmt.Sprintf("app-%s", site.Domain)
|
|
deploy.Start(unitName)
|
|
writeJSON(w, http.StatusOK, map[string]string{"status": "started"})
|
|
}
|
|
|
|
func (h *Handler) SiteLogs(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := paramInt(r, "id")
|
|
site, _ := h.DB.GetSite(id)
|
|
if site == nil {
|
|
writeError(w, http.StatusNotFound, "site not found")
|
|
return
|
|
}
|
|
|
|
unitName := fmt.Sprintf("app-%s", site.Domain)
|
|
out, _ := deploy.Logs(unitName, 100)
|
|
|
|
if acceptsJSON(r) {
|
|
writeJSON(w, http.StatusOK, map[string]string{"logs": out})
|
|
return
|
|
}
|
|
h.render(w, "site_detail.html", map[string]interface{}{
|
|
"Site": site,
|
|
"Logs": out,
|
|
})
|
|
}
|
|
|
|
func (h *Handler) SiteLogStream(w http.ResponseWriter, r *http.Request) {
|
|
id, _ := paramInt(r, "id")
|
|
site, _ := h.DB.GetSite(id)
|
|
if site == nil {
|
|
writeError(w, http.StatusNotFound, "site not found")
|
|
return
|
|
}
|
|
|
|
unitName := fmt.Sprintf("app-%s", site.Domain)
|
|
streamJournalctl(w, r, unitName)
|
|
}
|
|
|
|
func (h *Handler) generateAppUnit(site *db.Site) {
|
|
var execStart string
|
|
|
|
switch site.AppType {
|
|
case "python":
|
|
venvPython := filepath.Join(site.AppRoot, "venv", "bin", "python3")
|
|
execStart = fmt.Sprintf("%s %s", venvPython, filepath.Join(site.AppRoot, site.AppEntry))
|
|
case "node":
|
|
execStart = fmt.Sprintf("/usr/bin/node %s", filepath.Join(site.AppRoot, site.AppEntry))
|
|
case "autarch":
|
|
venvPython := filepath.Join(site.AppRoot, "venv", "bin", "python3")
|
|
execStart = fmt.Sprintf("%s %s", venvPython, filepath.Join(site.AppRoot, "autarch_web.py"))
|
|
default:
|
|
return
|
|
}
|
|
|
|
unitName := fmt.Sprintf("app-%s", site.Domain)
|
|
unitContent := deploy.GenerateUnit(deploy.UnitConfig{
|
|
Name: unitName,
|
|
Description: fmt.Sprintf("%s (%s)", site.Domain, site.AppType),
|
|
ExecStart: execStart,
|
|
WorkingDirectory: site.AppRoot,
|
|
User: "root",
|
|
Environment: map[string]string{"PYTHONUNBUFFERED": "1"},
|
|
})
|
|
|
|
deploy.InstallUnit(unitName, unitContent)
|
|
deploy.Enable(unitName)
|
|
}
|
|
|
|
func acceptsJSON(r *http.Request) bool {
|
|
accept := r.Header.Get("Accept")
|
|
return strings.Contains(accept, "application/json")
|
|
}
|
|
|
|
func streamJournalctl(w http.ResponseWriter, r *http.Request, unit string) {
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
writeError(w, http.StatusInternalServerError, "streaming not supported")
|
|
return
|
|
}
|
|
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
|
|
cmd := exec.Command("journalctl", "-u", unit, "-f", "-n", "50", "--no-pager", "-o", "short-iso")
|
|
stdout, err := cmd.StdoutPipe()
|
|
if err != nil {
|
|
return
|
|
}
|
|
cmd.Start()
|
|
defer cmd.Process.Kill()
|
|
|
|
buf := make([]byte, 4096)
|
|
for {
|
|
select {
|
|
case <-r.Context().Done():
|
|
return
|
|
default:
|
|
n, err := stdout.Read(buf)
|
|
if err != nil {
|
|
return
|
|
}
|
|
if n > 0 {
|
|
fmt.Fprintf(w, "data: %s\n\n", strings.TrimSpace(string(buf[:n])))
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
}
|
|
}
|