init from gitlab

This commit is contained in:
texm
2023-04-25 14:33:14 +08:00
parent 6a85a41ff0
commit c8202a5c82
281 changed files with 19861 additions and 1 deletions

View File

@@ -0,0 +1,103 @@
package apps
import (
"fmt"
"github.com/labstack/echo/v4"
"github.com/texm/dokku-go"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/server/dto"
"net/http"
)
func GetAppBuilder(e *env.Env, c echo.Context) error {
var req dto.GetAppBuilderRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
report, err := e.Dokku.GetAppBuilderReport(req.Name)
if err != nil {
return fmt.Errorf("getting app builder: %w", err)
}
selectedBuilder := report.ComputedSelectedBuilder
if selectedBuilder == "" {
selectedBuilder = "auto"
}
return c.JSON(http.StatusOK, dto.GetAppBuilderResponse{
Selected: selectedBuilder,
})
}
func SetAppBuilder(e *env.Env, c echo.Context) error {
var req dto.SetAppBuilderRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
builders := map[string]dokku.AppBuilder{
"auto": "",
"dockerfile": dokku.AppBuilderDockerfile,
"herokuish": dokku.AppBuilderHerokuish,
"null": dokku.AppBuilderNull,
"buildpack": dokku.AppBuilderPack,
"lambda": dokku.AppBuilderLambda,
}
chosenBuilder, supported := builders[req.Builder]
if !supported {
return echo.NewHTTPError(http.StatusBadRequest,
fmt.Sprintf("unsupported builder '%s'", req.Builder))
}
err := e.Dokku.SetAppSelectedBuilder(req.Name, chosenBuilder)
if err != nil {
return fmt.Errorf("setting app builder: %w", err)
}
return c.NoContent(http.StatusOK)
}
func GetAppBuildDirectory(e *env.Env, c echo.Context) error {
var req dto.GetAppBuildDirectoryRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
report, err := e.Dokku.GetAppBuilderReport(req.Name)
if err != nil {
return fmt.Errorf("getting app build dir: %w", err)
}
return c.JSON(http.StatusOK, dto.GetAppBuildDirectoryResponse{
Directory: report.ComputedBuildDir,
})
}
func SetAppBuildDirectory(e *env.Env, c echo.Context) error {
var req dto.SetAppBuildDirectoryRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
err := e.Dokku.SetAppBuilderProperty(req.Name, dokku.BuilderPropertyBuildDir, req.Directory)
if err != nil {
return fmt.Errorf("setting app build dir: %w", err)
}
return c.NoContent(http.StatusOK)
}
func ClearAppBuildDirectory(e *env.Env, c echo.Context) error {
var req dto.ClearAppBuildDirectoryRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
err := e.Dokku.SetAppBuilderProperty(req.Name, dokku.BuilderPropertyBuildDir, "")
if err != nil {
return fmt.Errorf("clearing app build dir: %w", err)
}
return c.NoContent(http.StatusOK)
}

View File

@@ -0,0 +1,38 @@
package apps
import (
"fmt"
"github.com/labstack/echo/v4"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/server/dto"
"net/http"
)
func GetAppConfig(e *env.Env, c echo.Context) error {
var req dto.GetAppConfigRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
config, err := e.Dokku.GetAppConfig(req.Name)
if err != nil {
return fmt.Errorf("getting app config: %w", err)
}
return c.JSON(http.StatusOK, dto.GetAppConfigResponse{
Config: config,
})
}
func SetAppConfig(e *env.Env, c echo.Context) error {
var req dto.SetAppConfigRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
if err := e.Dokku.SetAppConfigValues(req.Name, req.Config, false); err != nil {
return fmt.Errorf("setting app config: %w", err)
}
return c.NoContent(http.StatusOK)
}

View File

@@ -0,0 +1,97 @@
package apps
import (
"fmt"
"github.com/labstack/echo/v4"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/server/dto"
"net/http"
)
func GetAppDomainsReport(e *env.Env, c echo.Context) error {
var req dto.GetAppDomainsReportRequest
if err := dto.BindRequest(c, &req); err != nil {
return echo.ErrBadRequest
}
report, err := e.Dokku.GetAppDomainsReport(req.Name)
if err != nil {
return fmt.Errorf("getting app domains report: %w", err)
}
if len(report.AppDomains) == 0 {
report.AppDomains = make([]string, 0)
}
if len(report.GlobalDomains) == 0 {
report.GlobalDomains = make([]string, 0)
}
return c.JSON(http.StatusOK, dto.GetAppDomainsReportResponse{
Domains: report.AppDomains,
Enabled: report.AppEnabled,
})
}
func SetAppDomainsEnabled(e *env.Env, c echo.Context) error {
var req dto.SetAppDomainsEnabledRequest
if err := dto.BindRequest(c, &req); err != nil {
return echo.ErrBadRequest
}
var err error
if req.Enabled {
err = e.Dokku.EnableAppDomains(req.Name)
} else {
err = e.Dokku.DisableAppDomains(req.Name)
}
if err != nil {
return fmt.Errorf("setting app domains enabled: %w", err)
}
return c.NoContent(http.StatusOK)
}
func GetAppLetsEncryptEnabled(e *env.Env, c echo.Context) error {
var req dto.GetAppLetsEncryptEnabledRequest
if err := dto.BindRequest(c, &req); err != nil {
return echo.ErrBadRequest
}
return c.NoContent(http.StatusNotImplemented)
}
func SetAppLetsEncryptEnabled(e *env.Env, c echo.Context) error {
var req dto.SetAppLetsEncryptEnabledRequest
if err := dto.BindRequest(c, &req); err != nil {
return echo.ErrBadRequest
}
return c.NoContent(http.StatusNotImplemented)
}
func AddAppDomain(e *env.Env, c echo.Context) error {
var req dto.AlterAppDomainRequest
if err := dto.BindRequest(c, &req); err != nil {
return echo.ErrBadRequest
}
if err := e.Dokku.AddAppDomain(req.Name, req.Domain); err != nil {
return fmt.Errorf("adding app domain: %w", err)
}
return c.NoContent(http.StatusOK)
}
func RemoveAppDomain(e *env.Env, c echo.Context) error {
var req dto.AlterAppDomainRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
if err := e.Dokku.RemoveAppDomain(req.Name, req.Domain); err != nil {
return fmt.Errorf("removing app domain: %w", err)
}
return c.NoContent(http.StatusOK)
}

View File

@@ -0,0 +1,415 @@
package apps
import (
"errors"
"fmt"
"github.com/rs/zerolog/log"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/models"
"gitlab.com/texm/shokku/internal/server/commands"
"gitlab.com/texm/shokku/internal/server/dto"
"net/http"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/texm/dokku-go"
)
const FilteredApp = "shokku"
func lookupDBAppByName(e *env.Env, name string) (*models.App, error) {
dbApp := models.App{Name: name}
res := e.DB.Where("name = ?", name).Find(&dbApp)
if res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return nil, fmt.Errorf("no app found for %s", name)
}
return &dbApp, nil
}
func GetAppsList(e *env.Env, c echo.Context) error {
appsList, err := e.Dokku.ListApps()
apps := make([]dto.GetAppsListItem, 0)
for _, name := range appsList {
if name != FilteredApp {
apps = append(apps, dto.GetAppsListItem{
Name: name,
// TODO: get type
Type: "",
})
}
}
if err != nil && !errors.Is(err, dokku.NoDeployedAppsError) {
return fmt.Errorf("getting apps overview: %w", err)
}
return c.JSON(http.StatusOK, dto.GetAppsListResponse{
Apps: apps,
})
}
func GetAppsProcessReport(e *env.Env, c echo.Context) error {
allReports, err := e.Dokku.GetAllProcessReport()
if err != nil {
return fmt.Errorf("failed to get apps report: %w", err)
}
apps := make([]dto.GetAppOverviewResponse, 0)
for name, psReport := range allReports {
if name == FilteredApp {
continue
}
app, lookupErr := lookupDBAppByName(e, name)
if lookupErr != nil {
return fmt.Errorf("failed to lookup app %s: %w", name, lookupErr)
}
apps = append(apps, dto.GetAppOverviewResponse{
Name: name,
IsSetup: app.IsSetup,
SetupMethod: app.SetupMethod,
IsDeployed: psReport.Deployed,
IsRunning: psReport.Running,
NumProcesses: psReport.Processes,
CanScale: psReport.CanScale,
Restore: psReport.Restore,
})
}
return c.JSON(http.StatusOK, dto.GetAllAppsOverviewResponse{
Apps: apps,
})
}
func GetAppOverview(e *env.Env, c echo.Context) error {
var req dto.GetAppOverviewRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
app, lookupErr := lookupDBAppByName(e, req.Name)
if lookupErr != nil {
return fmt.Errorf("failed to lookup app %s: %w", req.Name, lookupErr)
}
psReport, psErr := e.Dokku.GetAppProcessReport(req.Name)
if psErr != nil {
return fmt.Errorf("getting apps process report: %w", psErr)
}
gitReport, gitErr := e.Dokku.GitGetAppReport(req.Name)
if gitErr != nil {
return fmt.Errorf("getting app git report: %w", gitErr)
}
return c.JSON(http.StatusOK, dto.GetAppOverviewResponse{
IsSetup: app.IsSetup,
SetupMethod: app.SetupMethod,
IsDeployed: psReport.Deployed,
GitDeployBranch: gitReport.DeployBranch,
GitLastUpdated: gitReport.LastUpdatedAt,
IsRunning: psReport.Running,
})
}
func GetAppInfo(e *env.Env, c echo.Context) error {
var req dto.GetAppInfoRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
info, err := e.Dokku.GetAppReport(req.Name)
if err != nil {
return fmt.Errorf("getting app info: %w", err)
}
res := &dto.GetAppInfoResponse{
Info: dto.AppInfo{
Name: req.Name,
Directory: info.Directory,
DeploySource: info.DeploySource,
DeploySourceMetadata: info.DeploySourceMetadata,
CreatedAt: time.UnixMilli(info.CreatedAtTimestamp),
IsLocked: info.IsLocked,
},
}
return c.JSON(http.StatusOK, res)
}
func CreateApp(e *env.Env, c echo.Context) error {
var req dto.ManageAppRequest
if reqErr := dto.BindRequest(c, &req); reqErr != nil {
log.Debug().
Err(reqErr.ToHTTP()).
Str("appName", req.Name).
Msg("bind err")
return reqErr.ToHTTP()
}
_, lookupErr := lookupDBAppByName(e, req.Name)
if lookupErr == nil {
return echo.ErrBadRequest
}
if createErr := e.Dokku.CreateApp(req.Name); createErr != nil {
return fmt.Errorf("creating app: %w", createErr)
}
if dbErr := e.DB.Create(&models.App{Name: req.Name}).Error; dbErr != nil {
log.Error().Err(dbErr).Str("name", req.Name).Msg("failed to create db app")
return echo.ErrInternalServerError
}
return c.NoContent(http.StatusOK)
}
func DestroyApp(e *env.Env, c echo.Context) error {
var req dto.DestroyAppRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
dbApp, dbErr := lookupDBAppByName(e, req.Name)
if dbErr != nil {
log.Error().Err(dbErr).Str("name", req.Name).Msg("failed to lookup app")
return echo.ErrNotFound
}
if err := e.Dokku.DestroyApp(req.Name); err != nil {
return fmt.Errorf("destroying app: %w", err)
}
// TODO: hard delete app
if err := e.DB.Delete(&dbApp).Error; err != nil {
log.Error().Err(err).Str("name", req.Name).Msg("failed to delete app")
return echo.ErrInternalServerError
}
return c.NoContent(http.StatusOK)
}
func RenameApp(e *env.Env, c echo.Context) error {
var req dto.RenameAppRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
dbApp, dbErr := lookupDBAppByName(e, req.CurrentName)
if dbErr != nil {
log.Error().Err(dbErr).Str("name", req.CurrentName).
Msg("failed to lookup app")
return echo.ErrNotFound
}
if _, newDbErr := lookupDBAppByName(e, req.NewName); newDbErr == nil {
return echo.ErrBadRequest
}
dbApp.Name = req.NewName
if saveErr := e.DB.Save(&dbApp).Error; saveErr != nil {
log.Error().Err(saveErr).
Str("name", req.NewName).
Msg("failed to save db app")
}
options := &dokku.AppManagementOptions{SkipDeploy: true}
if renameErr := e.Dokku.RenameApp(req.CurrentName, req.NewName, options); renameErr != nil {
return fmt.Errorf("renaming app: %w", renameErr)
}
return c.NoContent(http.StatusOK)
}
func StartApp(e *env.Env, c echo.Context) error {
var req dto.ManageAppRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
cmd := func() (*dokku.CommandOutputStream, error) {
return e.Dokku.StartApp(req.Name, nil)
}
return c.JSON(http.StatusOK, dto.CommandExecutionResponse{
ExecutionID: commands.RequestExecution(cmd, nil),
})
}
func StopApp(e *env.Env, c echo.Context) error {
var req dto.ManageAppRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
cmd := func() (*dokku.CommandOutputStream, error) {
return e.Dokku.StopApp(req.Name, nil)
}
return c.JSON(http.StatusOK, dto.CommandExecutionResponse{
ExecutionID: commands.RequestExecution(cmd, nil),
})
}
func RestartApp(e *env.Env, c echo.Context) error {
var req dto.ManageAppRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
cmd := func() (*dokku.CommandOutputStream, error) {
return e.Dokku.RestartApp(req.Name, nil)
}
return c.JSON(http.StatusOK, dto.CommandExecutionResponse{
ExecutionID: commands.RequestExecution(cmd, nil),
})
}
func RebuildApp(e *env.Env, c echo.Context) error {
var req dto.ManageAppRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
dbApp, appErr := lookupDBAppByName(e, req.Name)
if appErr != nil {
return echo.NewHTTPError(http.StatusBadRequest, "couldnt get app")
}
if !dbApp.IsSetup {
return echo.NewHTTPError(http.StatusBadRequest, "not setup")
}
var cfg models.AppSetupConfig
cfgErr := e.DB.Where("app_id = ?", dbApp.ID).First(&cfg).Error
if cfgErr != nil {
return echo.NewHTTPError(http.StatusInternalServerError, "no setup config")
}
method := setupMethod(dbApp.SetupMethod)
var cmd commands.AsyncDokkuCommand
if method == methodGit {
cmd = func() (*dokku.CommandOutputStream, error) {
return e.Dokku.RebuildApp(req.Name, nil)
}
} else if method == methodSyncRepo {
opts := &dokku.GitSyncOptions{
Build: true,
GitRef: cfg.RepoGitRef,
}
cmd = func() (*dokku.CommandOutputStream, error) {
return e.Dokku.GitSyncAppRepo(req.Name, cfg.RepoURL, opts)
}
} else if method == methodDocker {
image := cfg.Image
cmd = func() (*dokku.CommandOutputStream, error) {
return e.Dokku.GitCreateFromImage(req.Name, image, nil)
}
} else {
log.Error().
Str("method", dbApp.SetupMethod).
Str("name", dbApp.Name).
Msg("invalid app setup method")
return echo.NewHTTPError(http.StatusBadRequest, "invalid setup method")
}
return c.JSON(http.StatusOK, dto.CommandExecutionResponse{
ExecutionID: commands.RequestExecution(cmd, nil),
})
}
func GetAppDeployChecks(e *env.Env, c echo.Context) error {
var req dto.GetAppDeployChecksRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
report, err := e.Dokku.GetAppDeployChecksReport(req.Name)
if err != nil {
return fmt.Errorf("getting app deploy checks: %w", err)
}
return c.JSON(http.StatusOK, dto.GetAppDeployChecksResponse{
AllDisabled: report.AllDisabled,
AllSkipped: report.AllSkipped,
DisabledProcesses: report.DisabledProcesses,
SkippedProcesses: report.SkippedProcesses,
})
}
func SetAppDeployChecks(e *env.Env, c echo.Context) error {
var req dto.SetAppDeployChecksRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
var err error
switch req.State {
case "enabled":
err = e.Dokku.EnableAppDeployChecks(req.Name)
case "disabled":
err = e.Dokku.DisableAppDeployChecks(req.Name)
case "skipped":
err = e.Dokku.SetAppDeployChecksSkipped(req.Name)
default:
return echo.NewHTTPError(http.StatusBadRequest, "unknown state")
}
if err != nil {
return fmt.Errorf("setting app deploy checks to %s: %w", req.State, err)
}
return c.NoContent(http.StatusOK)
}
func GetAppLogs(e *env.Env, c echo.Context) error {
var req dto.GetAppLogsRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
logs, err := e.Dokku.GetAppLogs(req.Name)
if err != nil {
if errors.Is(err, dokku.AppNotDeployedError) {
return c.JSON(http.StatusOK, dto.GetAppLogsResponse{})
}
return fmt.Errorf("getting app logs: %w", err)
}
return c.JSON(http.StatusOK, dto.GetAppLogsResponse{
Logs: strings.Split(logs, "\n"),
})
}
/*
func AppExecInProcess(e *env.Env, c echo.Context) error {
var req dto.AppExecInProcessRequest
if err := dto.BindRequest(c, &req); err != nil {
log.Debug().Str("err", err.String()).Interface("req", req).Msg("bind")
return err.ToHTTP()
}
cmd := fmt.Sprintf(`enter %s %s %s`, req.AppName, req.ProcessName, req.Command)
output, execErr := e.Dokku.Exec(cmd)
res := dto.AppExecInProcessResponse{
Output: output,
}
if execErr != nil {
res.Error = execErr.Error()
var sshExitErr *dokku.ExitCodeError
if errors.As(execErr, &sshExitErr) {
res.Error = sshExitErr.Output()
}
}
return c.JSON(http.StatusOK, res)
}
*/

View File

@@ -0,0 +1,84 @@
package apps
import (
"fmt"
"github.com/labstack/echo/v4"
"github.com/texm/dokku-go"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/server/dto"
"net/http"
)
func GetAppNetworksReport(e *env.Env, c echo.Context) error {
var req dto.GetAppNetworksReportRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
report, err := e.Dokku.GetAppNetworkReport(req.Name)
if err != nil {
return fmt.Errorf("getting app network report: %w", err)
}
return c.JSON(http.StatusOK, dto.GetAppNetworksReportResponse{
AttachInitial: report.ComputedInitialNetwork,
AttachPostCreate: report.ComputedAttachPostCreate,
AttachPostDeploy: report.ComputedAttachPostDeploy,
BindAllInterfaces: report.ComputedBindAllInterfaces,
TLD: report.ComputedTLD,
WebListeners: report.WebListeners,
})
}
func SetAppNetworks(e *env.Env, c echo.Context) error {
var req dto.SetAppNetworksRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
if req.Initial != nil {
err := e.Dokku.SetAppNetworkProperty(req.Name,
dokku.NetworkPropertyInitialNetwork, *req.Initial)
if err != nil {
return fmt.Errorf("setting initial network: %w", err)
}
}
if req.PostCreate != nil {
err := e.Dokku.SetAppNetworkProperty(req.Name,
dokku.NetworkPropertyAttachPostCreate, *req.PostCreate)
if err != nil {
return fmt.Errorf("setting postcreate network: %w", err)
}
}
if req.PostDeploy != nil {
err := e.Dokku.SetAppNetworkProperty(req.Name,
dokku.NetworkPropertyAttachPostDeploy, *req.PostDeploy)
if err != nil {
return fmt.Errorf("setting postdeploy network: %w", err)
}
}
if req.BindAllInterfaces != nil {
bindAll := "false"
if *req.BindAllInterfaces {
bindAll = "true"
}
err := e.Dokku.SetAppNetworkProperty(req.Name,
dokku.NetworkPropertyBindAllInterfaces, bindAll)
if err != nil {
return fmt.Errorf("setting bind-all-interfaces: %w", err)
}
}
if req.TLD != nil {
err := e.Dokku.SetAppNetworkProperty(req.Name,
dokku.NetworkPropertyTLD, *req.TLD)
if err != nil {
return fmt.Errorf("setting TLD: %w", err)
}
}
return c.NoContent(http.StatusOK)
}

View File

@@ -0,0 +1,190 @@
package apps
import (
"fmt"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"github.com/texm/dokku-go"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/server/commands"
"gitlab.com/texm/shokku/internal/server/dto"
"net/http"
)
func GetAppProcesses(e *env.Env, c echo.Context) error {
var req dto.GetAppProcessesRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
scales, err := e.Dokku.GetAppProcessScale(req.Name)
if err != nil {
return fmt.Errorf("getting app process scale: %w", err)
}
processes := make([]string, len(scales))
i := 0
for processName := range scales {
processes[i] = processName
i++
}
return c.JSON(http.StatusOK, dto.GetAppProcessesResponse{
Processes: processes,
})
}
func GetAppProcessReport(e *env.Env, c echo.Context) error {
var req dto.GetAppProcessReportRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
resourceReport, err := e.Dokku.GetAppResourceReport(req.Name)
if err != nil {
return fmt.Errorf("getting app resource report: %w", err)
}
processScale, err := e.Dokku.GetAppProcessScale(req.Name)
if err != nil {
return fmt.Errorf("getting app process scale: %w", err)
}
processMap := map[string]dto.AppProcessInfo{}
for processName, scale := range processScale {
appResources := dto.AppProcessInfo{
Scale: scale,
}
if psSettings, ok := resourceReport.Processes[processName]; ok {
appResources.Resources = psSettings
}
processMap[processName] = appResources
}
return c.JSON(http.StatusOK, dto.GetAppProcessReportResponse{
ResourceDefaults: resourceReport.Defaults,
Processes: processMap,
})
}
func SetAppProcessResources(e *env.Env, c echo.Context) error {
var req dto.SetAppProcessResourcesRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
limits := req.ResourceLimits
reservations := req.ResourceReservations
if limits.CPU != nil {
err := e.Dokku.SetAppProcessResourceLimit(req.Name, req.Process,
dokku.ResourceCPU, *limits.CPU)
if err != nil {
return fmt.Errorf("setting app cpu limit: %w", err)
}
} else {
err := e.Dokku.ClearAppProcessResourceLimit(req.Name, req.Process, dokku.ResourceCPU)
if err != nil {
return fmt.Errorf("clearing app cpu limit: %w", err)
}
}
if limits.Memory != nil && limits.MemoryUnit != nil {
resSpec := dokku.ResourceSpec{
Name: "memory",
Suffix: *limits.MemoryUnit,
}
err := e.Dokku.SetAppProcessResourceLimit(req.Name, req.Process, resSpec, *limits.Memory)
if err != nil {
return fmt.Errorf("setting app mem limit: %w", err)
}
} else {
err := e.Dokku.ClearAppProcessResourceLimit(req.Name, req.Process, dokku.ResourceMemoryBytes)
if err != nil {
return fmt.Errorf("clearing app mem limit: %w", err)
}
}
if reservations.Memory != nil && reservations.MemoryUnit != nil {
resSpec := dokku.ResourceSpec{
Name: "memory",
Suffix: *reservations.MemoryUnit,
}
err := e.Dokku.SetAppProcessResourceReservation(req.Name, req.Process, resSpec,
*reservations.Memory)
if err != nil {
return fmt.Errorf("setting app mem reservation: %w", err)
}
} else {
err := e.Dokku.ClearAppProcessResourceReservation(req.Name, req.Process,
dokku.ResourceMemoryBytes)
if err != nil {
return fmt.Errorf("clearing app mem reservation: %w", err)
}
}
return c.NoContent(http.StatusOK)
}
func GetAppProcessScale(e *env.Env, c echo.Context) error {
var req dto.GetAppProcessScaleRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
scale, err := e.Dokku.GetAppProcessScale(req.Name)
if err != nil {
return fmt.Errorf("getting app process scale: %w", err)
}
return c.JSON(http.StatusOK, dto.GetAppProcessScaleResponse{
ProcessScale: scale,
})
}
func SetAppProcessDeployChecks(e *env.Env, c echo.Context) error {
var req dto.SetAppProcessDeployChecksRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
processes := []string{req.Process}
var err error
switch req.State {
case "enabled":
err = e.Dokku.EnableAppProcessesDeployChecks(req.Name, processes)
case "disabled":
err = e.Dokku.DisableAppProcessesDeployChecks(req.Name, processes)
case "skipped":
err = e.Dokku.SetAppProcessesDeployChecksSkipped(req.Name, processes)
default:
return echo.NewHTTPError(http.StatusBadRequest, "unknown state")
}
if err != nil {
return fmt.Errorf("setting app deploy checks to " + req.State)
}
return c.NoContent(http.StatusOK)
}
func SetAppProcessScale(e *env.Env, c echo.Context) error {
var req dto.SetAppProcessScaleRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
_, err := e.Auth.GetUserFromContext(c)
if err != nil {
log.Error().Err(err).Msg("failed to retrieve user from context")
return echo.ErrInternalServerError
}
cmd := func() (*dokku.CommandOutputStream, error) {
return e.Dokku.SetAppProcessScale(req.Name, req.Process, req.Scale, req.SkipDeploy)
}
return c.JSON(http.StatusOK, dto.CommandExecutionResponse{
ExecutionID: commands.RequestExecution(cmd, nil),
})
}

View File

@@ -0,0 +1,70 @@
package apps
import (
"github.com/labstack/echo/v4"
"gitlab.com/texm/shokku/internal/env"
)
func RegisterRoutes(e *env.Env, g *echo.Group) {
g.GET("/list", e.H(GetAppsList))
g.GET("/report", e.H(GetAppsProcessReport))
g.GET("/overview", e.H(GetAppOverview))
g.POST("/create", e.H(CreateApp))
g.POST("/start", e.H(StartApp))
g.POST("/stop", e.H(StopApp))
g.POST("/restart", e.H(RestartApp))
g.POST("/rebuild", e.H(RebuildApp))
setupGroup := g.Group("/setup")
setupGroup.GET("/status", e.H(GetAppSetupStatus))
setupGroup.GET("/config", e.H(GetAppSetupConfig))
setupGroup.POST("/new-repo", e.H(SetupAppNewRepo))
setupGroup.POST("/sync-repo", e.H(SetupAppSyncRepo))
setupGroup.POST("/pull-image", e.H(SetupAppPullImage))
setupGroup.POST("/upload-archive", e.H(SetupAppUploadArchive))
g.POST("/destroy", e.H(DestroyApp))
g.GET("/info", e.H(GetAppInfo))
g.POST("/rename", e.H(RenameApp))
g.GET("/services", e.H(GetAppServices))
g.GET("/deploy-checks", e.H(GetAppDeployChecks))
g.POST("/deploy-checks", e.H(SetAppDeployChecks))
process := g.Group("/process")
process.GET("/list", e.H(GetAppProcesses))
process.GET("/report", e.H(GetAppProcessReport))
process.POST("/deploy-checks", e.H(SetAppProcessDeployChecks))
process.POST("/resources", e.H(SetAppProcessResources))
process.GET("/scale", e.H(GetAppProcessScale))
process.POST("/scale", e.H(SetAppProcessScale))
g.GET("/letsencrypt", e.H(GetAppLetsEncryptEnabled))
g.POST("/letsencrypt", e.H(SetAppLetsEncryptEnabled))
g.GET("/domains", e.H(GetAppDomainsReport))
g.POST("/domains/state", e.H(SetAppDomainsEnabled))
g.POST("/domain", e.H(AddAppDomain))
g.DELETE("/domain", e.H(RemoveAppDomain))
g.GET("/networks", e.H(GetAppNetworksReport))
g.POST("/networks", e.H(SetAppNetworks))
g.GET("/logs", e.H(GetAppLogs))
g.GET("/config", e.H(GetAppConfig))
g.POST("/config", e.H(SetAppConfig))
g.GET("/storage", e.H(GetAppStorage))
g.POST("/storage/mount", e.H(MountAppStorage))
g.POST("/storage/unmount", e.H(UnmountAppStorage))
g.GET("/builder", e.H(GetAppBuilder))
g.POST("/builder", e.H(SetAppBuilder))
g.GET("/build-directory", e.H(GetAppBuildDirectory))
g.POST("/build-directory", e.H(SetAppBuildDirectory))
g.DELETE("/build-directory", e.H(ClearAppBuildDirectory))
}

View File

@@ -0,0 +1,60 @@
package apps
import (
"fmt"
"github.com/labstack/echo/v4"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/server/dto"
"net/http"
"strings"
)
var (
dokkuErrPrefix = "! "
serviceTypes = []string{"redis", "postgres", "mysql", "mongo"}
)
func splitDokkuListOutput(output string) ([]string, error) {
if strings.HasPrefix(output, dokkuErrPrefix) {
return nil, nil
}
if output == "" {
return []string{}, nil
}
return strings.Split(output, "\n"), nil
}
func getAppServiceLinks(e *env.Env, appName string, serviceType string) ([]string, error) {
linksCmd := fmt.Sprintf("%s:app-links %s --quiet", serviceType, appName)
out, err := e.Dokku.Exec(linksCmd)
if err != nil {
return nil, err
}
return splitDokkuListOutput(out)
}
func GetAppServices(e *env.Env, c echo.Context) error {
var req dto.GetAppServicesRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
serviceList := []dto.ServiceInfo{}
for _, serviceType := range serviceTypes {
links, err := getAppServiceLinks(e, req.Name, serviceType)
if err != nil {
return fmt.Errorf("getting links for " + serviceType)
}
for _, name := range links {
serviceList = append(serviceList, dto.ServiceInfo{
Name: name,
Type: serviceType,
})
}
}
return c.JSON(http.StatusOK, dto.ListServicesResponse{
Services: serviceList,
})
}

View File

@@ -0,0 +1,229 @@
package apps
import (
"fmt"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"github.com/texm/dokku-go"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/models"
"gitlab.com/texm/shokku/internal/server/auth"
"gitlab.com/texm/shokku/internal/server/commands"
"gitlab.com/texm/shokku/internal/server/dto"
"gorm.io/gorm/clause"
"net/http"
"net/url"
)
type setupMethod string
const (
methodGit = setupMethod("git")
methodSyncRepo = setupMethod("sync-repo")
methodDocker = setupMethod("docker")
methodArchive = setupMethod("archive")
)
var (
updateOnConflict = clause.OnConflict{
DoUpdates: clause.AssignmentColumns([]string{"updated_at"}),
}
)
func saveAppSetupMethod(e *env.Env, app *models.App, method setupMethod, cfg *models.AppSetupConfig) error {
app.IsSetup = true
app.SetupMethod = string(method)
if appErr := e.DB.Save(app).Error; appErr != nil {
log.Error().Err(appErr).
Str("app", app.Name).
Str("method", app.SetupMethod).
Msg("failed to save app table")
return fmt.Errorf("failed to save app table: %w", appErr)
}
cfg.AppID = app.ID
updateErr := e.DB.Clauses(updateOnConflict).Create(&cfg).Error
if updateErr != nil {
log.Error().Err(updateErr).
Str("app", app.Name).
Interface("cfg", cfg).
Msg("failed to update app setup config")
return fmt.Errorf("failed to save app setup config: %w", updateErr)
}
return nil
}
func GetAppSetupStatus(e *env.Env, c echo.Context) error {
var req dto.GetAppSetupStatusRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
dbApp, appErr := lookupDBAppByName(e, req.Name)
if appErr != nil {
return echo.ErrNotFound
}
return c.JSON(http.StatusOK, dto.GetAppSetupStatusResponse{
IsSetup: dbApp.IsSetup,
Method: dbApp.SetupMethod,
})
}
func GetAppSetupConfig(e *env.Env, c echo.Context) error {
var req dto.GetAppSetupConfigRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
dbApp, appErr := lookupDBAppByName(e, req.Name)
if appErr != nil {
return echo.ErrNotFound
}
var cfg models.AppSetupConfig
e.DB.Where("app_id = ?", dbApp.ID).FirstOrInit(&cfg)
return c.JSON(http.StatusOK, dto.GetAppSetupConfigResponse{
IsSetup: dbApp.IsSetup,
Method: dbApp.SetupMethod,
DeploymentBranch: cfg.DeployBranch,
RepoURL: cfg.RepoURL,
RepoGitRef: cfg.RepoGitRef,
Image: cfg.Image,
})
}
func SetupAppNewRepo(e *env.Env, c echo.Context) error {
var req dto.SetupAppNewRepoRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
dbApp, appErr := lookupDBAppByName(e, req.Name)
if appErr != nil {
return echo.ErrNotFound
}
if initErr := e.Dokku.GitInitializeApp(req.Name); initErr != nil {
return fmt.Errorf("initialising git repo: %w", initErr)
}
branch := req.DeploymentBranch
if branch == "" {
branch = "master"
}
if branch != "master" {
branchErr := e.Dokku.GitSetAppProperty(req.Name, dokku.GitPropertyDeployBranch, req.DeploymentBranch)
if branchErr != nil {
return fmt.Errorf("setting git deploy branch: %w", branchErr)
}
}
cfg := &models.AppSetupConfig{
DeployBranch: branch,
}
if saveErr := saveAppSetupMethod(e, dbApp, methodGit, cfg); saveErr != nil {
return saveErr
}
return c.NoContent(http.StatusOK)
}
func SetupAppSyncRepo(e *env.Env, c echo.Context) error {
var req dto.SetupAppSyncRepoRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
dbApp, appErr := lookupDBAppByName(e, req.Name)
if appErr != nil {
return echo.ErrNotFound
}
parsedURL, urlErr := url.Parse(req.RepositoryURL)
if urlErr != nil {
return echo.NewHTTPError(http.StatusBadRequest, "invalid repo url")
}
if e.Auth.GetMethod() == auth.MethodGithub {
if parsedURL.Hostname() != "github.com" {
// return echo.ErrBadRequest
}
}
syncOpts := &dokku.GitSyncOptions{
Build: true,
GitRef: req.GitRef,
}
execFn := func() (*dokku.CommandOutputStream, error) {
return e.Dokku.GitSyncAppRepo(req.Name, req.RepositoryURL, syncOpts)
}
cfg := &models.AppSetupConfig{
AppID: dbApp.ID,
RepoURL: req.RepositoryURL,
RepoGitRef: req.GitRef,
}
cb := func() error {
return saveAppSetupMethod(e, dbApp, methodSyncRepo, cfg)
}
return c.JSON(http.StatusOK, dto.CommandExecutionResponse{
ExecutionID: commands.RequestExecution(execFn, cb),
})
}
func SetupAppPullImage(e *env.Env, c echo.Context) error {
var req dto.SetupAppPullImageRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
dbApp, appErr := lookupDBAppByName(e, req.Name)
if appErr != nil {
return echo.ErrNotFound
}
execFn := func() (*dokku.CommandOutputStream, error) {
return e.Dokku.GitCreateFromImage(req.Name, req.Image, nil)
}
cfg := &models.AppSetupConfig{
AppID: dbApp.ID,
Image: req.Image,
}
cb := func() error {
return saveAppSetupMethod(e, dbApp, methodDocker, cfg)
}
return c.JSON(http.StatusOK, dto.CommandExecutionResponse{
ExecutionID: commands.RequestExecution(execFn, cb),
})
}
func SetupAppUploadArchive(e *env.Env, c echo.Context) error {
var req dto.SetupAppUploadArchiveRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
// disable for now
if true {
return echo.ErrForbidden
}
file, err := c.FormFile("archive")
if err != nil {
log.Error().Err(err).Msg("invalid form file")
return echo.ErrInternalServerError
}
log.Info().
Str("app", req.Name).
Msgf("got file: %+v", file.Header)
return c.NoContent(http.StatusNotImplemented)
}

View File

@@ -0,0 +1,107 @@
package apps
import (
"fmt"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"github.com/texm/dokku-go"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/server/dto"
"net/http"
)
func isMountInSlice(mount dokku.StorageBindMount, mounts []dokku.StorageBindMount) bool {
for _, m := range mounts {
if m == mount {
return true
}
}
return false
}
func GetAppStorage(e *env.Env, c echo.Context) error {
var req dto.GetAppStorageRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
report, err := e.Dokku.GetAppStorageReport(req.Name)
if err != nil {
return fmt.Errorf("getting app storage report: %w", err)
}
var allMounts []dokku.StorageBindMount
allMounts = append(allMounts, report.RunMounts...)
allMounts = append(allMounts, report.DeployMounts...)
allMounts = append(allMounts, report.BuildMounts...)
seenMap := map[string]bool{}
mounts := []dto.StorageMount{}
for _, dokkuMount := range allMounts {
if _, seen := seenMap[dokkuMount.String()]; seen {
continue
}
seenMap[dokkuMount.String()] = true
mounts = append(mounts, dto.StorageMount{
HostDir: dokkuMount.HostDir,
ContainerDir: dokkuMount.ContainerDir,
IsBuildMount: isMountInSlice(dokkuMount, report.BuildMounts),
IsRunMount: isMountInSlice(dokkuMount, report.RunMounts),
IsDeployMount: isMountInSlice(dokkuMount, report.DeployMounts),
})
}
return c.JSON(http.StatusOK, dto.GetAppStorageResponse{
Mounts: mounts,
})
}
func MountAppStorage(e *env.Env, c echo.Context) error {
var req dto.AlterAppStorageRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
if req.StorageType == "New Storage" {
// TODO: actually chown properly
err := e.Dokku.EnsureStorageDirectory(req.Name, dokku.StorageChownOptionHerokuish)
if err != nil {
return fmt.Errorf("ensuring app storage dir: %w", err)
}
}
mount := dokku.StorageBindMount{
HostDir: req.HostDir,
ContainerDir: req.ContainerDir,
}
if err := e.Dokku.MountAppStorage(req.Name, mount); err != nil {
return fmt.Errorf("mounting app storage dir: %w", err)
}
return c.NoContent(http.StatusOK)
}
func UnmountAppStorage(e *env.Env, c echo.Context) error {
var req dto.AlterAppStorageRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
mount := dokku.StorageBindMount{
HostDir: req.HostDir,
ContainerDir: req.ContainerDir,
}
if err := e.Dokku.UnmountAppStorage(req.Name, mount); err != nil {
return fmt.Errorf("unmounting app storage dir: %w", err)
}
if req.RestartApp {
go func() {
if _, err := e.Dokku.RestartApp(req.Name, nil); err != nil {
log.Error().Err(err).Msg("error while restarting app")
}
}()
}
return c.NoContent(http.StatusOK)
}

View File

@@ -0,0 +1,47 @@
package auth
import (
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/server/auth"
"net/http"
)
func HandleLogout(e *env.Env, c echo.Context) error {
e.Auth.ClearTokenCookies(c)
return c.NoContent(http.StatusOK)
}
func HandleRefreshAuth(e *env.Env, c echo.Context) error {
user, err := e.Auth.GetUserFromContext(c)
if err != nil {
log.Error().Msg("failed to parse user from context")
return echo.ErrInternalServerError
}
claims := auth.UserClaims{
Name: user.Name,
}
token, err := e.Auth.NewToken(claims)
if err != nil {
log.Error().Err(err).Msg("failed to create jwt")
return echo.ErrInternalServerError
}
e.Auth.SetTokenCookies(c, token)
return c.NoContent(http.StatusOK)
}
func HandleGetDetails(e *env.Env, c echo.Context) error {
user, err := e.Auth.GetUserFromContext(c)
if err != nil {
log.Error().Msg("failed to parse user from context")
return echo.ErrInternalServerError
}
return c.JSON(http.StatusOK, echo.Map{
"username": user.Name,
})
}

View File

@@ -0,0 +1,76 @@
package auth
import (
"context"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/models"
"gitlab.com/texm/shokku/internal/server/auth"
"gitlab.com/texm/shokku/internal/server/dto"
"gitlab.com/texm/shokku/internal/server/github"
"net/http"
)
func GetGithubAuthInfo(e *env.Env, c echo.Context) error {
var ghApp models.GithubApp
if err := e.DB.First(&ghApp).Error; err != nil {
log.Error().Err(err).Msg("no github app in db")
return echo.ErrBadRequest
}
return c.JSON(http.StatusOK, dto.GetGithubAuthInfoResponse{
ClientID: ghApp.ClientId,
})
}
func CompleteGithubAuth(e *env.Env, c echo.Context) error {
var req dto.GithubAuthRequest
if err := c.Bind(&req); err != nil {
return echo.ErrBadRequest
}
ctx := context.Background()
params := github.CodeExchangeParams{
Code: req.Code,
Scopes: []string{},
RedirectURL: req.RedirectURL,
}
client, err := github.ExchangeCode(ctx, e, params)
if err != nil {
log.Error().Err(err).Msg("failed to exchange code for client")
return echo.ErrBadRequest
}
user, err := client.GetUser(ctx)
if err != nil {
log.Error().Err(err).Msg("failed to get user")
return echo.ErrBadRequest
}
var count int64
dbUser := models.User{Name: user.GetLogin()}
r := e.DB.Model(&dbUser).Where(&dbUser).Count(&count)
if r.Error != nil {
log.Error().
Err(r.Error).
Str("name", user.GetLogin()).
Msg("user lookup error")
return echo.ErrInternalServerError
}
if count == 0 {
return echo.ErrForbidden
}
claims := auth.UserClaims{
Name: user.GetLogin(),
}
token, err := e.Auth.NewToken(claims)
if err != nil {
log.Error().Err(err).Msg("failed to create jwt")
return echo.ErrInternalServerError
}
e.Auth.SetTokenCookies(c, token)
return c.NoContent(http.StatusOK)
}

View File

@@ -0,0 +1,58 @@
package auth
import (
"github.com/pquerna/otp/totp"
"github.com/rs/zerolog/log"
"gitlab.com/texm/shokku/internal/env"
auth "gitlab.com/texm/shokku/internal/server/auth"
"gitlab.com/texm/shokku/internal/server/dto"
"net/http"
"github.com/labstack/echo/v4"
"gitlab.com/texm/shokku/internal/models"
)
func HandlePasswordLogin(e *env.Env, c echo.Context) error {
var req dto.PasswordLoginRequest
if err := c.Bind(&req); err != nil {
return echo.ErrBadRequest
}
var dbUser models.User
res := e.DB.Where("name = ?", req.Username).Take(&dbUser)
if res.Error != nil {
return echo.ErrForbidden
}
pwAuth, ok := e.Auth.(*auth.PasswordAuthenticator)
if !ok {
log.Error().Msg("failed to cast authenticator to pw auth")
return echo.ErrInternalServerError
}
if !pwAuth.VerifyHash([]byte(req.Password), dbUser.PasswordHash) {
return echo.ErrForbidden
}
if dbUser.TotpEnabled {
if !totp.Validate(req.TotpCode, dbUser.TotpSecret) {
return c.JSON(http.StatusOK, dto.PasswordLoginResponse{
Success: true,
NeedsTotp: true,
})
}
}
claims := auth.UserClaims{
Name: req.Username,
}
token, err := e.Auth.NewToken(claims)
if err != nil {
log.Error().Err(err).Msg("failed to create jwt")
return echo.ErrInternalServerError
}
e.Auth.SetTokenCookies(c, token)
return c.JSON(http.StatusOK, dto.PasswordLoginResponse{
Success: true,
})
}

View File

@@ -0,0 +1,16 @@
package auth
import (
"github.com/labstack/echo/v4"
"gitlab.com/texm/shokku/internal/env"
)
func RegisterRoutes(e *env.Env, g *echo.Group, authG *echo.Group) {
g.POST("/login", e.H(HandlePasswordLogin))
g.GET("/github", e.H(GetGithubAuthInfo))
g.POST("/github/auth", e.H(CompleteGithubAuth))
g.POST("/logout", e.H(HandleLogout))
authG.GET("/details", e.H(HandleGetDetails))
authG.POST("/refresh", e.H(HandleRefreshAuth))
}

View File

@@ -0,0 +1,34 @@
package api
import (
"errors"
"github.com/labstack/echo/v4"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/server/commands"
"gitlab.com/texm/shokku/internal/server/dto"
"net/http"
)
func GetCommandExecutionStatus(e *env.Env, c echo.Context) error {
var req dto.GetCommandExecutionStatusRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
status, err := commands.GetExecutionStatus(req.ExecutionID)
if err != nil {
if errors.Is(err, commands.ErrNotPolled) {
return c.JSON(http.StatusOK, dto.CommandExecutionStatusResponse{
Started: false,
})
}
return echo.ErrNotFound
}
return c.JSON(http.StatusOK, dto.CommandExecutionStatusResponse{
CombinedOutput: status.CombinedOutput,
Started: true,
Finished: status.Finished,
Success: status.StreamError == nil,
})
}

View File

@@ -0,0 +1,58 @@
package api
import (
"github.com/google/go-github/v48/github"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/models"
"net/http"
)
func ReceiveGithubWebhookEvent(e *env.Env, c echo.Context) error {
var ghApp models.GithubApp
if ghErr := e.DB.First(&ghApp).Error; ghErr != nil {
log.Error().Err(ghErr).Msg("failed to retrieve github app info")
return echo.ErrInternalServerError
}
req := c.Request()
secret := []byte(ghApp.WebhookSecret)
payload, validationErr := github.ValidatePayload(req, secret)
if validationErr != nil {
return echo.ErrBadRequest
}
event, parseErr := github.ParseWebHook(github.WebHookType(req), payload)
if parseErr != nil {
log.Error().Err(parseErr).Msg("failed to parse webhook")
return echo.ErrInternalServerError
}
var err error
switch event := event.(type) {
case *github.MetaEvent:
err = processMetaEvent(e, event)
case *github.PushEvent:
err = processPushEvent(e, event)
default:
log.Error().
Interface("type", event).
Msg("received unsupported webhook event")
return echo.ErrInternalServerError
}
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err)
}
return c.NoContent(http.StatusOK)
}
func processMetaEvent(e *env.Env, event *github.MetaEvent) error {
log.Debug().Interface("event", event).Msg("got meta event")
return nil
}
func processPushEvent(e *env.Env, event *github.PushEvent) error {
return nil
}

View File

@@ -0,0 +1,26 @@
package api
import (
"github.com/labstack/echo/v4"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/server/api/apps"
"gitlab.com/texm/shokku/internal/server/api/auth"
"gitlab.com/texm/shokku/internal/server/api/services"
"gitlab.com/texm/shokku/internal/server/api/settings"
"gitlab.com/texm/shokku/internal/server/api/setup"
)
func RegisterRoutes(e *env.Env, authMiddleware []echo.MiddlewareFunc) {
g := e.Router.Group("/api")
setup.RegisterRoutes(e, g.Group("/setup"))
auth.RegisterRoutes(e, g.Group("/auth"), g.Group("/auth", authMiddleware...))
protectedApi := g.Group("", authMiddleware...)
apps.RegisterRoutes(e, protectedApi.Group("/apps"))
services.RegisterRoutes(e, protectedApi.Group("/services"))
settings.RegisterRoutes(e, protectedApi.Group("/settings"))
protectedApi.GET("/exec/status", e.H(GetCommandExecutionStatus))
g.POST("/github/events", e.H(ReceiveGithubWebhookEvent))
}

View File

@@ -0,0 +1,57 @@
package api
import (
"fmt"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/server/dto"
"net/http"
"net/http/httptest"
"net/url"
"testing"
)
func createQueryContext(route string, params url.Values) (*env.Env, echo.Context) {
e := env.NewTestingEnvironment()
uri := fmt.Sprintf("%s/?%s", route, params.Encode())
req := httptest.NewRequest(http.MethodGet, uri, nil)
rec := httptest.NewRecorder()
c := e.Router.NewContext(req, rec)
e.Router.GET("/ping", e.H(pingRoute))
return e, c
}
type pingRequest struct {
Foo string `query:"foo" validate:"alphanum"`
}
func pingRoute(e *env.Env, c echo.Context) error {
var req pingRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
return c.NoContent(http.StatusOK)
}
func TestPingRouteValidatesSuccess(t *testing.T) {
q := make(url.Values)
q.Set("foo", "bar")
e, c := createQueryContext("/ping", q)
err := pingRoute(e, c)
assert.NoError(t, err)
}
func TestPingRouteValidatesFail(t *testing.T) {
q := make(url.Values)
q.Set("foo", "bar!!")
e, c := createQueryContext("/ping", q)
err := pingRoute(e, c)
assert.Error(t, err)
}

View File

@@ -0,0 +1,209 @@
package services
import (
"fmt"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"github.com/texm/dokku-go"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/server/commands"
"gitlab.com/texm/shokku/internal/server/dto"
"net/http"
)
func GetServiceBackupReport(e *env.Env, c echo.Context) error {
var req dto.GetServiceBackupReportRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
dbSvc, err := lookupDBServiceByName(e, req.Name)
if err != nil {
return echo.ErrNotFound
}
cmd := fmt.Sprintf("%s:backup-schedule-cat %s", dbSvc.Type, req.Name)
backupSchedule, err := e.Dokku.Exec(cmd)
if err != nil {
backupSchedule = ""
}
report := dto.ServiceBackupReport{
AuthSet: dbSvc.BackupAuthSet,
EncryptionSet: dbSvc.BackupEncryptionSet,
Bucket: dbSvc.BackupBucket,
Schedule: backupSchedule,
}
return c.JSON(http.StatusOK, dto.GetServiceBackupReportResponse{
Report: report,
})
}
func SetServiceBackupAuth(e *env.Env, c echo.Context) error {
var req dto.SetServiceBackupsAuthRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
dbSvc, err := lookupDBServiceByName(e, req.Name)
if err != nil {
return echo.ErrNotFound
}
cfg := req.Config
args := fmt.Sprintf("%s %s %s %s %s", cfg.AccessKeyId, cfg.SecretKey,
cfg.Region, cfg.SignatureVersion, cfg.EndpointUrl)
cmd := fmt.Sprintf("%s:backup-auth %s %s", dbSvc.Type, req.Name, args)
if _, execErr := e.Dokku.Exec(cmd); execErr != nil {
return fmt.Errorf("setting backup auth: %w", execErr)
}
dbSvc.BackupAuthSet = true
if err := e.DB.Save(&dbSvc).Error; err != nil {
log.Error().Err(err).Msg("error updating service backup auth")
return echo.ErrInternalServerError
}
return c.NoContent(http.StatusOK)
}
func SetServiceBackupBucket(e *env.Env, c echo.Context) error {
var req dto.SetServiceBackupsBucketRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
dbSvc, err := lookupDBServiceByName(e, req.Name)
if err != nil {
return echo.ErrNotFound
}
dbSvc.BackupBucket = req.Bucket
if dbErr := e.DB.Save(&dbSvc).Error; dbErr != nil {
log.Error().Err(dbErr).Msg("error updating service backup bucket")
return echo.ErrInternalServerError
}
return c.NoContent(http.StatusOK)
}
func RunServiceBackup(e *env.Env, c echo.Context) error {
var req dto.RunServiceBackupRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
dbSvc, err := lookupDBServiceByName(e, req.Name)
if err != nil {
return echo.ErrNotFound
}
if dbSvc.BackupBucket == "" {
return echo.NewHTTPError(http.StatusBadRequest, "backup bucket not set")
} else if !dbSvc.BackupAuthSet {
return echo.NewHTTPError(http.StatusBadRequest, "backup auth not set")
}
dokkuCmd := fmt.Sprintf("%s:backup %s %s", dbSvc.Type, dbSvc.Name, dbSvc.BackupBucket)
cmd := func() (*dokku.CommandOutputStream, error) {
return e.Dokku.ExecStreaming(dokkuCmd)
}
return c.JSON(http.StatusOK, dto.CommandExecutionResponse{
ExecutionID: commands.RequestExecution(cmd, nil),
})
}
func SetServiceBackupSchedule(e *env.Env, c echo.Context) error {
var req dto.SetServiceBackupsScheduleRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
dbSvc, err := lookupDBServiceByName(e, req.Name)
if err != nil {
return echo.ErrNotFound
}
if dbSvc.BackupBucket == "" {
return echo.NewHTTPError(http.StatusBadRequest,
"service backup bucket not set")
}
cmd := fmt.Sprintf(`%s:backup-schedule %s "%s" %s`, dbSvc.Type,
req.Name, req.Schedule, dbSvc.BackupBucket)
if out, err := e.Dokku.Exec(cmd); err != nil {
log.Debug().Str("output", out).Msg("backup schedule output")
return fmt.Errorf("setting backup schedule: %w", err)
}
return c.NoContent(http.StatusOK)
}
func RemoveServiceBackupSchedule(e *env.Env, c echo.Context) error {
var req dto.ManageServiceRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
cmd := fmt.Sprintf(`%s:backup-unschedule %s`, req.Type, req.Name)
if out, err := e.Dokku.Exec(cmd); err != nil {
log.Debug().Str("output", out).Msg("backup schedule output")
return fmt.Errorf("removing backup schedule: %w", err)
}
return c.NoContent(http.StatusOK)
}
func SetServiceBackupEncryption(e *env.Env, c echo.Context) error {
var req dto.SetServiceBackupsEncryptionRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
dbSvc, err := lookupDBServiceByName(e, req.Name)
if err != nil {
return echo.ErrNotFound
}
cmd := fmt.Sprintf(`%s:backup-set-encryption %s %s`, dbSvc.Type,
req.Name, req.Passphrase)
if out, err := e.Dokku.Exec(cmd); err != nil {
log.Debug().Str("output", out).Msg("set backup encryption output")
return fmt.Errorf("setting backup encryption: %w", err)
}
dbSvc.BackupEncryptionSet = true
if saveErr := e.DB.Save(&dbSvc).Error; saveErr != nil {
log.Error().Err(saveErr).Msg("error updating service backup encryption")
return echo.ErrInternalServerError
}
return c.NoContent(http.StatusOK)
}
func RemoveServiceBackupEncryption(e *env.Env, c echo.Context) error {
var req dto.ManageServiceRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
dbSvc, err := lookupDBServiceByName(e, req.Name)
if err != nil {
return echo.ErrNotFound
}
cmd := fmt.Sprintf(`%s:backup-unset-encryption %s`, req.Type, req.Name)
if out, err := e.Dokku.Exec(cmd); err != nil {
log.Debug().Str("output", out).Msg("unset backup encryption output")
return fmt.Errorf("removing backup encryption: %w", err)
}
dbSvc.BackupEncryptionSet = false
if saveErr := e.DB.Save(&dbSvc).Error; saveErr != nil {
log.Error().Err(saveErr).Msg("error updating service backup encryption")
return echo.ErrInternalServerError
}
return c.NoContent(http.StatusOK)
}

View File

@@ -0,0 +1,167 @@
package services
import (
"errors"
"fmt"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"github.com/texm/dokku-go"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/models"
"gitlab.com/texm/shokku/internal/server/dto"
"net/http"
"strings"
)
var (
ErrDestroyingLinkedService = echo.NewHTTPError(http.StatusBadRequest,
"cannot destroy a linked service")
ErrServiceNameTaken = echo.NewHTTPError(http.StatusBadRequest,
"service name exists")
)
func manageService(e *env.Env, c echo.Context, mgmt string, flags string) error {
var req dto.GenericServiceRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
cmd := fmt.Sprintf("%s:%s %s %s", req.Type, mgmt, req.Name, flags)
if _, err := e.Dokku.Exec(cmd); err != nil {
return fmt.Errorf(mgmt+"ing service", err)
}
return c.NoContent(http.StatusOK)
}
func StartService(e *env.Env, c echo.Context) error {
return manageService(e, c, "start", "")
}
func StopService(e *env.Env, c echo.Context) error {
return manageService(e, c, "stop", "")
}
func RestartService(e *env.Env, c echo.Context) error {
return manageService(e, c, "restart", "")
}
func DestroyService(e *env.Env, c echo.Context) error {
var req dto.GenericServiceRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
dbSvc, lookupErr := lookupDBServiceByName(e, req.Name)
if lookupErr != nil {
return echo.ErrNotFound
}
cmd := fmt.Sprintf("%s:destroy %s -f", req.Type, req.Name)
if _, err := e.Dokku.Exec(cmd); err != nil {
var dokkuErr *dokku.ExitCodeError
if errors.As(err, &dokkuErr) {
if strings.HasSuffix(dokkuErr.Output(), "Cannot delete linked service") {
return ErrDestroyingLinkedService
}
}
return fmt.Errorf("destroying service: %w", err)
}
if err := e.DB.Delete(&dbSvc).Error; err != nil {
log.Error().Err(err).Interface("svc", dbSvc).Msg("error deleting service")
return echo.ErrInternalServerError
}
return c.NoContent(http.StatusOK)
}
// use short opt strings otherwise arg parse is funky
func maybeAppendStringOptionFlag(flags *[]string, opt string, cfgOption *string) {
if cfgOption != nil {
*flags = append(*flags, fmt.Sprintf("-%s '%s'", opt, *cfgOption))
}
}
func getServiceCreateFlags(req dto.GenericServiceCreationConfig) string {
flags := &[]string{}
if req.CustomEnv != nil {
envVars := make([]string, len(*req.CustomEnv))
for i, e := range *req.CustomEnv {
envVars[i] = fmt.Sprintf("%s=%s", e[0], e[1])
}
envStr := strings.Join(envVars, ";")
*flags = append(*flags, fmt.Sprintf("-C \"%s\"", envStr))
}
maybeAppendStringOptionFlag(flags, "c", req.ConfigOptions)
maybeAppendStringOptionFlag(flags, "i", req.Image)
maybeAppendStringOptionFlag(flags, "m", req.MemoryLimit)
maybeAppendStringOptionFlag(flags, "p", req.Password)
maybeAppendStringOptionFlag(flags, "r", req.RootPassword)
maybeAppendStringOptionFlag(flags, "s", req.SharedMemorySize)
return strings.Join(*flags, " ")
}
func CreateNewGenericService(e *env.Env, c echo.Context) error {
var req dto.CreateGenericServiceRequest
if err := dto.BindRequest(c, &req); err != nil {
log.Debug().Err(err.ToHTTP()).Interface("req", req).Msgf("req failed")
return err.ToHTTP()
}
dbSvc := models.Service{
Name: req.Name,
Type: req.ServiceType,
}
if e.DB.Where("name = ?", req.Name).Find(&dbSvc).RowsAffected != 0 {
return ErrServiceNameTaken
}
e.DB.Save(&dbSvc)
flags := getServiceCreateFlags(req.Config)
cmd := fmt.Sprintf("%s:create %s %s", req.ServiceType, req.Name, flags)
_, err := e.Dokku.Exec(cmd)
if err != nil {
return fmt.Errorf("creating %s service: %w", req.ServiceType, err)
}
return c.NoContent(http.StatusOK)
}
func CloneService(e *env.Env, c echo.Context) error {
var req dto.CloneServiceRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
dbSvc, notFoundErr := lookupDBServiceByName(e, req.Name)
if notFoundErr != nil {
return echo.NewHTTPError(http.StatusNotFound, "service not found")
}
_, notFoundErr = lookupDBServiceByName(e, req.NewName)
if notFoundErr == nil {
return echo.NewHTTPError(http.StatusBadRequest, "new service name exists")
}
cmd := fmt.Sprintf("%s:clone %s %s", dbSvc.Type, req.Name, req.NewName)
_, err := e.Dokku.Exec(cmd)
if err != nil {
return fmt.Errorf("cloning %s service: %w", dbSvc.Type, err)
}
newSvc := models.Service{
Name: req.NewName,
Type: dbSvc.Type,
}
if err := e.DB.Save(&newSvc).Error; err != nil {
log.Error().Err(err).
Str("name", req.NewName).Str("type", dbSvc.Type).
Msg("failed to save cloned service to db")
return echo.ErrInternalServerError
}
return c.NoContent(http.StatusOK)
}

View File

@@ -0,0 +1,34 @@
package services
import (
"github.com/labstack/echo/v4"
"gitlab.com/texm/shokku/internal/env"
)
func RegisterRoutes(e *env.Env, g *echo.Group) {
g.GET("/list", e.H(ListServices))
g.GET("/info", e.H(GetServiceInfo))
g.GET("/type", e.H(GetServiceType))
g.GET("/logs", e.H(GetServiceLogs))
g.POST("/create", e.H(CreateNewGenericService))
g.POST("/clone", e.H(CloneService))
g.POST("/start", e.H(StartService))
g.POST("/stop", e.H(StopService))
g.POST("/restart", e.H(RestartService))
g.POST("/destroy", e.H(DestroyService))
g.POST("/link", e.H(LinkGenericServiceToApp))
g.POST("/unlink", e.H(UnlinkGenericServiceFromApp))
g.GET("/linked-apps", e.H(GetServiceLinkedApps))
backups := g.Group("/backups")
backups.GET("/report", e.H(GetServiceBackupReport))
backups.POST("/auth", e.H(SetServiceBackupAuth))
backups.POST("/bucket", e.H(SetServiceBackupBucket))
backups.POST("/run", e.H(RunServiceBackup))
backups.POST("/schedule", e.H(SetServiceBackupSchedule))
backups.DELETE("/schedule", e.H(RemoveServiceBackupSchedule))
backups.POST("/encryption", e.H(SetServiceBackupEncryption))
backups.DELETE("/encryption", e.H(RemoveServiceBackupEncryption))
}

View File

@@ -0,0 +1,220 @@
package services
import (
"fmt"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"github.com/texm/dokku-go"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/models"
"gitlab.com/texm/shokku/internal/server/commands"
"gitlab.com/texm/shokku/internal/server/dto"
"net/http"
"strings"
)
var (
dokkuErrPrefix = "! "
serviceTypes = []string{"redis", "postgres", "mysql", "mongo"}
)
func lookupDBServiceByName(e *env.Env, name string) (*models.Service, error) {
dbSvc := models.Service{
Name: name,
}
res := e.DB.Where("name = ?", name).Find(&dbSvc)
if res.Error != nil {
return nil, res.Error
}
if res.RowsAffected == 0 {
return nil, fmt.Errorf("no service found for %s", name)
}
return &dbSvc, nil
}
func splitDokkuListOutput(output string) ([]string, error) {
if strings.HasPrefix(output, dokkuErrPrefix) {
return nil, nil
}
if output == "" {
return []string{}, nil
}
return strings.Split(output, "\n"), nil
}
func getServiceAppLinks(e *env.Env, serviceName string, serviceType string) ([]string, error) {
linksCmd := fmt.Sprintf("%s:links %s --quiet", serviceType, serviceName)
out, err := e.Dokku.Exec(linksCmd)
if err != nil {
return nil, err
}
return splitDokkuListOutput(out)
}
func getServiceList(e *env.Env, serviceType string) ([]string, error) {
listCmd := fmt.Sprintf("%s:list --quiet", serviceType)
out, err := e.Dokku.Exec(listCmd)
if err != nil {
return nil, err
}
if strings.Contains(out, "There are no") {
return []string{}, nil
}
return splitDokkuListOutput(out)
}
func ListServices(e *env.Env, c echo.Context) error {
serviceList := []dto.ServiceInfo{}
for _, serviceType := range serviceTypes {
services, err := getServiceList(e, serviceType)
if err != nil {
return fmt.Errorf("getting list for %s services: %w", serviceType, err)
}
for _, name := range services {
serviceList = append(serviceList, dto.ServiceInfo{
Name: name,
Type: serviceType,
})
}
}
return c.JSON(http.StatusOK, dto.ListServicesResponse{
Services: serviceList,
})
}
func GetServiceType(e *env.Env, c echo.Context) error {
var req dto.GetServiceTypeRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
dbSvc, err := lookupDBServiceByName(e, req.Name)
if err != nil {
return echo.ErrNotFound
}
return c.JSON(http.StatusOK, dto.GetServiceTypeResponse{
Type: dbSvc.Type,
})
}
func GetServiceInfo(e *env.Env, c echo.Context) error {
var req dto.GenericServiceRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
info := map[string]string{
"version": "", "internal-ip": "", "status": "", // "dsn": "",
}
for key := range info {
cmd := fmt.Sprintf("%s:info %s --%s", req.Type, req.Name, key)
out, err := e.Dokku.Exec(cmd)
if err != nil {
return fmt.Errorf("getting service info: %w", err)
}
info[key] = out
}
return c.JSON(http.StatusOK, dto.GetServiceInfoResponse{
Info: info,
})
}
func getGenericLinkFlags(req dto.LinkGenericServiceToAppRequest) string {
var flags []string
if req.Alias != "" {
flag := fmt.Sprintf("--alias %s", req.Alias)
flags = append(flags, flag)
}
if req.QueryString != "" {
flag := fmt.Sprintf("--querystring %s", req.QueryString)
flags = append(flags, flag)
}
return strings.Join(flags, " ")
}
func LinkGenericServiceToApp(e *env.Env, c echo.Context) error {
var req dto.LinkGenericServiceToAppRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
dbSvc, err := lookupDBServiceByName(e, req.ServiceName)
if err != nil {
return echo.ErrNotFound
}
flags := getGenericLinkFlags(req)
linkCmd := fmt.Sprintf("%s:link %s %s %s",
dbSvc.Type, dbSvc.Name, req.AppName, flags)
cmd := func() (*dokku.CommandOutputStream, error) {
return e.Dokku.ExecStreaming(linkCmd)
}
return c.JSON(http.StatusOK, dto.CommandExecutionResponse{
ExecutionID: commands.RequestExecution(cmd, nil),
})
}
func UnlinkGenericServiceFromApp(e *env.Env, c echo.Context) error {
var req dto.LinkGenericServiceToAppRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
dbSvc, err := lookupDBServiceByName(e, req.ServiceName)
if err != nil {
return echo.ErrNotFound
}
unlinkCmd := fmt.Sprintf("%s:unlink %s %s", dbSvc.Type,
dbSvc.Name, req.AppName)
cmd := func() (*dokku.CommandOutputStream, error) {
return e.Dokku.ExecStreaming(unlinkCmd)
}
return c.JSON(http.StatusOK, dto.CommandExecutionResponse{
ExecutionID: commands.RequestExecution(cmd, nil),
})
}
func GetServiceLinkedApps(e *env.Env, c echo.Context) error {
var req dto.GenericServiceRequest
if err := dto.BindRequest(c, &req); err != nil {
log.Error().Err(err.ToHTTP()).Msg("error")
return err.ToHTTP()
}
apps, err := getServiceAppLinks(e, req.Name, req.Type)
if err != nil {
return fmt.Errorf("getting linked apps: %w", err)
}
return c.JSON(http.StatusOK, dto.GetServiceLinkedAppsResponse{
Apps: apps,
})
}
func GetServiceLogs(e *env.Env, c echo.Context) error {
var req dto.GenericServiceRequest
if err := dto.BindRequest(c, &req); err != nil {
log.Error().Err(err.ToHTTP()).Msg("error")
return err.ToHTTP()
}
cmd := fmt.Sprintf("%s:logs %s", req.Type, req.Name)
out, err := e.Dokku.Exec(cmd)
if err != nil {
return fmt.Errorf("getting linked apps: %w", err)
}
logs := strings.Split(out, "\n")
return c.JSON(http.StatusOK, dto.GetServiceLogsResponse{
Logs: logs,
})
}

View File

@@ -0,0 +1,46 @@
package settings
import (
"fmt"
"github.com/labstack/echo/v4"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/server/dto"
"net/http"
)
func CreateNetwork(e *env.Env, c echo.Context) error {
var req dto.AlterNetworkRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
if err := e.Dokku.CreateNetwork(req.Network); err != nil {
return fmt.Errorf("error creating network: %w", err)
}
return c.NoContent(http.StatusOK)
}
func DestroyNetwork(e *env.Env, c echo.Context) error {
var req dto.AlterNetworkRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
if err := e.Dokku.DestroyNetwork(req.Network); err != nil {
return fmt.Errorf("error destroying network: %w", err)
}
return c.NoContent(http.StatusOK)
}
func ListNetworks(e *env.Env, c echo.Context) error {
networks, err := e.Dokku.ListNetworks()
if err != nil {
return fmt.Errorf("error listing networks: %w", err)
}
return c.JSON(http.StatusOK, dto.ListNetworksResponse{
Networks: networks,
})
}

View File

@@ -0,0 +1,75 @@
package settings
import (
"errors"
"fmt"
"github.com/labstack/echo/v4"
"github.com/texm/dokku-go"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/server/dto"
"net/http"
)
func SetDockerRegistry(e *env.Env, c echo.Context) error {
var req dto.SetDockerRegistryRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
if err := e.Dokku.LoginDockerRegistry(req.Server, req.Username, req.Password); err != nil {
return echo.NewHTTPError(http.StatusForbidden, err.Error())
}
propErr := e.Dokku.SetAppDockerRegistryProperty("--global", dokku.DockerRegistryPropertyServer, req.Server)
if propErr != nil {
return echo.NewHTTPError(http.StatusInternalServerError, propErr.Error())
}
return c.NoContent(http.StatusOK)
}
func GetDockerRegistryReport(e *env.Env, c echo.Context) error {
report, err := e.Dokku.GetDockerRegistryReport()
if err != nil && !errors.Is(err, dokku.NoDeployedAppsError) {
return fmt.Errorf("failed to get registry report: %w", err)
}
var response dto.GetDockerRegistryReportResponse
for _, appReport := range report {
response.Server = appReport.GlobalServer
response.PushOnRelease = appReport.GlobalPushOnRelease
break
}
return c.JSON(http.StatusOK, response)
}
func AddGitAuth(e *env.Env, c echo.Context) error {
var req dto.AddGitAuthRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
if err := e.Dokku.GitSetAuth(req.Host, req.Username, req.Password); err != nil {
return echo.NewHTTPError(http.StatusForbidden, err.Error())
}
if err := e.Dokku.GitAllowHost(req.Host); err != nil {
return echo.NewHTTPError(http.StatusForbidden, err.Error())
}
return c.NoContent(http.StatusOK)
}
func RemoveGitAuth(e *env.Env, c echo.Context) error {
var req dto.RemoveGitAuthRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
if err := e.Dokku.GitRemoveAuth(req.Host); err != nil {
return echo.NewHTTPError(http.StatusForbidden, err.Error())
}
return c.NoContent(http.StatusOK)
}

View File

@@ -0,0 +1,33 @@
package settings
import (
"github.com/labstack/echo/v4"
"gitlab.com/texm/shokku/internal/env"
)
func RegisterRoutes(e *env.Env, g *echo.Group) {
g.GET("/versions", e.H(GetVersions))
g.GET("/events", e.H(GetEventLogs))
g.GET("/events/list", e.H(GetEventLogsList))
g.POST("/events", e.H(SetEventLoggingEnabled))
g.GET("/users", e.H(GetUsers))
g.GET("/ssh-keys", e.H(GetSSHKeys))
g.GET("/domains", e.H(GetGlobalDomains))
g.POST("/domains", e.H(AddGlobalDomain))
g.DELETE("/domains", e.H(RemoveGlobalDomain))
g.GET("/networks", e.H(ListNetworks))
g.POST("/networks", e.H(CreateNetwork))
g.DELETE("/networks", e.H(DestroyNetwork))
g.GET("/plugins", e.H(ListPlugins))
g.GET("/registry", e.H(GetDockerRegistryReport))
g.POST("/registry", e.H(SetDockerRegistry))
g.POST("/git-auth", e.H(AddGitAuth))
g.DELETE("/git-auth", e.H(RemoveGitAuth))
}

View File

@@ -0,0 +1,156 @@
package settings
import (
"fmt"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/models"
"gitlab.com/texm/shokku/internal/server/dto"
"net/http"
"github.com/labstack/echo/v4"
)
func GetVersions(e *env.Env, c echo.Context) error {
dokkuVersion, err := e.Dokku.GetDokkuVersion()
if err != nil {
return fmt.Errorf("getting dokku version: %w", err)
}
return c.JSON(http.StatusOK, &dto.GetVersionsResponse{
Dokku: dokkuVersion,
Shokku: e.Version,
})
}
func GetUsers(e *env.Env, c echo.Context) error {
var dbUsers []models.User
res := e.DB.Model(models.User{}).Preload("SSHKeys").Find(&dbUsers)
if res.Error != nil {
return fmt.Errorf("querying db users: %w", res.Error)
}
users := make([]dto.User, len(dbUsers))
for i, dbUser := range dbUsers {
keys := make([]string, len(dbUser.SSHKeys))
for j, key := range dbUser.SSHKeys {
keys[j] = key.Key
}
users[i] = dto.User{
Name: dbUser.Name,
Source: dbUser.Source,
SSHKeys: keys,
}
}
return c.JSON(http.StatusOK, dto.GetUsersResponse{
Users: users,
})
}
func GetSSHKeys(e *env.Env, c echo.Context) error {
keys, err := e.Dokku.ListSSHKeys()
if err != nil {
return fmt.Errorf("listing ssh keys: %w", err)
}
return c.JSON(http.StatusOK, &dto.GetSSHKeysResponse{
Keys: keys,
})
}
func GetGlobalDomains(e *env.Env, c echo.Context) error {
report, err := e.Dokku.GetGlobalDomainsReport()
if err != nil {
return fmt.Errorf("getting global domains report: %w", err)
}
if len(report.Domains) == 0 {
report.Domains = make([]string, 0)
}
return c.JSON(http.StatusOK, &dto.GetGlobalDomainsResponse{
Domains: report.Domains,
Enabled: report.Enabled,
})
}
func AddGlobalDomain(e *env.Env, c echo.Context) error {
var req dto.AlterGlobalDomainRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
// TODO: domain verification etc
if err := e.Dokku.AddGlobalDomain(req.Domain); err != nil {
return fmt.Errorf("adding global domain: %w", err)
}
return c.NoContent(http.StatusOK)
}
func RemoveGlobalDomain(e *env.Env, c echo.Context) error {
var req dto.DeleteGlobalDomainRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
// TODO: domain verification etc
if err := e.Dokku.RemoveGlobalDomain(req.Domain); err != nil {
return fmt.Errorf("removing global domain: %w", err)
}
return c.NoContent(http.StatusOK)
}
func SetEventLoggingEnabled(e *env.Env, c echo.Context) error {
var req dto.SetEventLoggingEnabledRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
if err := e.Dokku.SetEventLoggingEnabled(req.Enabled); err != nil {
return fmt.Errorf("setting event logging: %w", err)
}
return c.NoContent(http.StatusOK)
}
func GetEventLogsList(e *env.Env, c echo.Context) error {
events, err := e.Dokku.ListLoggedEvents()
if err != nil {
return fmt.Errorf("removing global domain: %w", err)
}
return c.JSON(http.StatusOK, dto.GetEventLogsListResponse{
Events: events,
})
}
func GetEventLogs(e *env.Env, c echo.Context) error {
logs, err := e.Dokku.GetEventLogs()
if err != nil {
return fmt.Errorf("getting event logs: %w", err)
}
return c.JSON(http.StatusOK, dto.GetEventLogsResponse{
Logs: logs,
})
}
func ListPlugins(e *env.Env, c echo.Context) error {
plugins, err := e.Dokku.ListPlugins()
if err != nil {
return fmt.Errorf("listing plugins: %w", err)
}
info := make([]dto.PluginInfo, len(plugins))
for i := 0; i < len(plugins); i++ {
p := plugins[i]
info[i] = dto.PluginInfo{
Name: p.Name,
Version: p.Version,
Enabled: p.Enabled,
Description: p.Description,
}
}
return c.JSON(http.StatusOK, &dto.ListPluginsResponse{
Plugins: info,
})
}

View File

@@ -0,0 +1,111 @@
package setup
import (
"context"
"database/sql"
"errors"
"fmt"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/models"
"gitlab.com/texm/shokku/internal/server/auth"
"gitlab.com/texm/shokku/internal/server/dto"
"gitlab.com/texm/shokku/internal/server/github"
"net/http"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
)
const (
githubAppInstallURL = "https://github.com/apps/%s/installations/new/permissions?target_id=%d"
)
func GetGithubSetupStatus(e *env.Env, c echo.Context) error {
var ghApp models.GithubApp
r := e.DB.Find(&ghApp)
if r.Error != nil && !errors.Is(r.Error, sql.ErrNoRows) {
log.Error().Err(r.Error).Msg("Failed to lookup github app")
return echo.ErrInternalServerError
}
return c.JSON(http.StatusOK, dto.GetGithubSetupStatus{
AppCreated: r.RowsAffected > 0,
})
}
func CreateGithubApp(e *env.Env, c echo.Context) error {
var req dto.CreateGithubAppRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
cfg, err := github.CompleteAppManifest(e, req.Code)
if err != nil {
return echo.NewHTTPError(http.StatusInternalServerError, err)
}
return c.JSON(http.StatusOK, dto.CreateGithubAppResponse{
Slug: cfg.GetSlug(),
})
}
func GetGithubAppInstallInfo(e *env.Env, c echo.Context) error {
ctx := context.Background()
client, clientErr := github.GetAppClient(e)
if clientErr != nil {
log.Error().Err(clientErr).Msg("failed to get app client")
return echo.NewHTTPError(http.StatusBadRequest, clientErr)
}
app, appErr := client.GetApp(ctx)
if appErr != nil {
log.Error().Err(appErr).Msg("failed to get github client app")
return echo.NewHTTPError(http.StatusBadRequest, appErr)
}
url := fmt.Sprintf(githubAppInstallURL, app.GetSlug(), app.Owner.GetID())
return c.JSON(http.StatusOK, dto.InstallGithubAppResponse{
InstallURL: url,
})
}
func CompleteGithubSetup(e *env.Env, c echo.Context) error {
var req dto.CompleteGithubSetupRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
client, clientErr := github.GetAppClient(e)
if clientErr != nil {
log.Error().Err(clientErr).Msg("failed to get app client")
return echo.ErrBadRequest
}
ctx := context.Background()
inst, _, instErr := client.Apps.GetInstallation(ctx, req.InstallationId)
if instErr != nil {
log.Error().
Err(instErr).
Int64("id", req.InstallationId).
Msg("failed to get installation")
return echo.ErrBadRequest
}
if err := setupServerWithAuthMethod(e, auth.MethodGithub); err != nil {
log.Error().Err(err).Msg("failed to setup github auth")
return echo.ErrInternalServerError
}
go func() {
if err := github.SyncUsersToDB(e); err != nil {
log.Error().Err(err).Msg("failed to sync github users")
}
}()
log.Debug().
Int64("id", inst.GetID()).
Msg("installed github app")
return c.NoContent(http.StatusOK)
}

View File

@@ -0,0 +1,101 @@
package setup
import (
"bytes"
"encoding/base64"
"fmt"
"github.com/rs/zerolog/log"
"gitlab.com/texm/shokku/internal/models"
"gitlab.com/texm/shokku/internal/server/auth"
"image/png"
"math/rand"
"net/http"
"github.com/labstack/echo/v4"
"github.com/pquerna/otp/totp"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/server/dto"
)
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
func generateRandomString(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = letterRunes[rand.Intn(len(letterRunes))]
}
return string(b)
}
func GenerateTotp(e *env.Env, c echo.Context) error {
key, err := totp.Generate(totp.GenerateOpts{
Issuer: "shokku",
AccountName: "admin account",
})
if err != nil {
return fmt.Errorf("failed to generate totp key: %w", err)
}
var imgBuf bytes.Buffer
img, imgErr := key.Image(160, 160)
if imgErr != nil {
return fmt.Errorf("failed to generate totp image: %w", imgErr)
}
if encErr := png.Encode(&imgBuf, img); encErr != nil {
return fmt.Errorf("failed to encode totp image to png: %w", encErr)
}
return c.JSON(http.StatusOK, dto.GenerateTotpResponse{
Secret: key.Secret(),
Image: base64.StdEncoding.EncodeToString(imgBuf.Bytes()),
RecoveryCode: generateRandomString(12),
})
}
func ConfirmTotp(e *env.Env, c echo.Context) error {
var req dto.ConfirmTotpRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
return c.JSON(http.StatusOK, dto.ConfirmTotpResponse{
Valid: totp.Validate(req.Code, req.Secret),
})
}
func CompletePasswordSetup(e *env.Env, c echo.Context) error {
var req dto.CompletePasswordSetupRequest
if err := dto.BindRequest(c, &req); err != nil {
return err.ToHTTP()
}
if err := setupServerWithAuthMethod(e, auth.MethodPassword); err != nil {
log.Error().Err(err).Msg("failed to setup github auth")
return echo.ErrInternalServerError
}
pw, ok := e.Auth.(*auth.PasswordAuthenticator)
if !ok {
log.Error().Msg("failed to cast e.Auth to pw auth")
return echo.ErrInternalServerError
}
passwordHash, pwErr := pw.HashPassword([]byte(req.Password))
if pwErr != nil {
log.Error().Err(pwErr).Msg("failed to hash password")
return echo.ErrInternalServerError
}
user := models.User{
Name: req.Username,
Source: "manual",
PasswordHash: passwordHash,
TotpEnabled: req.Enable2FA,
RecoveryCode: req.RecoveryCode,
TotpSecret: req.TotpSecret,
}
if err := e.DB.Save(&user).Error; err != nil {
log.Error().Err(err).Msg("failed to save initial password auth user")
return echo.ErrInternalServerError
}
return c.NoContent(http.StatusOK)
}

View File

@@ -0,0 +1,20 @@
package setup
import (
"github.com/labstack/echo/v4"
"gitlab.com/texm/shokku/internal/env"
)
func RegisterRoutes(e *env.Env, g *echo.Group) {
g.GET("/status", e.H(GetStatus))
g.GET("/verify-key", e.H(GetSetupKeyValid))
g.POST("/github/create-app", e.H(CreateGithubApp))
g.GET("/github/install-info", e.H(GetGithubAppInstallInfo))
g.GET("/github/status", e.H(GetGithubSetupStatus))
g.POST("/github/completed", e.H(CompleteGithubSetup))
g.POST("/password", e.H(CompletePasswordSetup))
g.POST("/totp/new", e.H(GenerateTotp))
g.POST("/totp/confirm", e.H(ConfirmTotp))
}

View File

@@ -0,0 +1,65 @@
package setup
import (
"fmt"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/models"
"gitlab.com/texm/shokku/internal/server/auth"
"gitlab.com/texm/shokku/internal/server/dto"
"net/http"
)
func GetStatus(e *env.Env, c echo.Context) error {
method := string(e.Auth.GetMethod())
log.Debug().
Bool("is_setup", e.SetupCompleted).
Str("method", method).
Msg("get setup status")
return c.JSON(http.StatusOK, dto.GetSetupStatusResponse{
IsSetup: e.SetupCompleted,
Method: method,
})
}
func GetSetupKeyValid(e *env.Env, c echo.Context) error {
return c.NoContent(http.StatusOK)
}
func setupServerWithAuthMethod(e *env.Env, method auth.Method) error {
var state models.Server
e.DB.FirstOrCreate(&state)
state.IsSetup = true
state.AuthMethod = method
if err := e.DB.Save(&state).Error; err != nil {
log.Error().Err(err).Msg("failed to save setup state")
return echo.ErrInternalServerError
}
newAuth, authErr := createAuthenticator(e, method)
if authErr != nil {
log.Error().Err(authErr).Msg("failed to init new authenticator")
return echo.ErrInternalServerError
}
e.Auth = newAuth
e.SetupCompleted = true
return nil
}
func createAuthenticator(e *env.Env, method auth.Method) (auth.Authenticator, error) {
config := auth.Config{
SigningKey: e.Auth.GetSigningKey(),
CookieDomain: e.Auth.GetCookieDomain(),
TokenLifetime: e.Auth.GetTokenLifetime(),
}
switch method {
case auth.MethodGithub:
return auth.NewGithubAuthenticator(config)
case auth.MethodPassword:
return auth.NewPasswordAuthenticator(config, auth.DefaultBCryptCost)
}
return nil, fmt.Errorf("unknown method %s", method)
}

43
internal/server/auth.go Normal file
View File

@@ -0,0 +1,43 @@
package server
import (
"fmt"
"gitlab.com/texm/shokku/internal/server/auth"
"time"
)
type initAuthConfig struct {
SigningKey []byte
TokenLifetime time.Duration
Method auth.Method
DebugMode bool
IsSetup bool
}
func initAuthenticator(cfg initAuthConfig) (auth.Authenticator, error) {
authCfg := auth.Config{
SigningKey: cfg.SigningKey,
TokenLifetime: cfg.TokenLifetime,
}
if !cfg.IsSetup {
return auth.NewNoneAuthenticator(authCfg)
}
bCryptCost := 14
if cfg.DebugMode {
// make hashing faster in dev
bCryptCost = 3
}
switch cfg.Method {
case auth.MethodPassword:
return auth.NewPasswordAuthenticator(authCfg, bCryptCost)
case auth.MethodGithub:
return auth.NewGithubAuthenticator(authCfg)
case auth.MethodNone:
return auth.NewNoneAuthenticator(authCfg)
}
return nil, fmt.Errorf("unsupported auth method '%s'", cfg.Method)
}

View File

@@ -0,0 +1,151 @@
package auth
import (
"errors"
"github.com/golang-jwt/jwt"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"strings"
"time"
)
const (
ContextUserKey = "user"
DataCookieName = "auth_data"
SignatureCookieName = "auth_sig"
)
type Method string
const (
MethodNone = Method("none")
MethodPassword = Method("password")
MethodGithub = Method("github")
)
var (
ErrNoTokenInContext = errors.New("no token value in context")
ErrNoUserInContext = errors.New("failed to retrieve user from context")
ErrClaimsInvalid = errors.New("failed to cast jwt claims")
)
type Authenticator interface {
NewToken(claims UserClaims) (string, error)
SetUserContext(c echo.Context, contextKey string) error
GetUserFromContext(c echo.Context) (*User, error)
SetTokenCookies(c echo.Context, jwt string) string
ClearTokenCookies(c echo.Context)
GetSigningKey() []byte
GetCookieDomain() string
GetTokenLifetime() time.Duration
GetMethod() Method
}
type User struct {
UserClaims
jwt.StandardClaims
}
type UserClaims struct {
Name string `json:"name"`
}
type baseAuthenticator struct {
signingKey []byte
authMethod Method
cookieDomain string
tokenLifetime time.Duration
}
type Config struct {
SigningKey []byte
CookieDomain string
TokenLifetime time.Duration
}
func (a *baseAuthenticator) NewToken(claims UserClaims) (string, error) {
expiry := time.Now().Add(a.tokenLifetime)
stdClaims := jwt.StandardClaims{
ExpiresAt: expiry.Unix(),
}
user := &User{
UserClaims: claims,
StandardClaims: stdClaims,
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, user)
return token.SignedString(a.signingKey)
}
func (a *baseAuthenticator) SetUserContext(c echo.Context, contextKey string) error {
token, ok := c.Get(contextKey).(*jwt.Token)
if !ok {
return ErrNoTokenInContext
}
u, ok := token.Claims.(*User)
if !ok {
return ErrClaimsInvalid
}
c.Set(ContextUserKey, u)
return nil
}
func (a *baseAuthenticator) GetUserFromContext(c echo.Context) (*User, error) {
user, ok := c.Get(ContextUserKey).(*User)
if !ok {
log.Error().Msg("failed to retrieve user from context")
return nil, ErrNoUserInContext
}
return user, nil
}
func (a *baseAuthenticator) SetTokenCookies(c echo.Context, jwt string) string {
splitToken := strings.Split(jwt, ".")
dataCookieValue := strings.Join(splitToken[:2], ".")
signatureCookieValue := splitToken[2]
// accessible to the js frontend
dataCookieValues := authCookieValues{
name: DataCookieName,
value: dataCookieValue,
httpOnly: false,
lifetime: a.tokenLifetime,
}
c.SetCookie(makeAuthCookie(dataCookieValues))
// inaccessible to the js frontend
signatureCookieValues := authCookieValues{
name: SignatureCookieName,
value: signatureCookieValue,
httpOnly: true,
lifetime: a.tokenLifetime,
}
c.SetCookie(makeAuthCookie(signatureCookieValues))
return dataCookieValue
}
func (a *baseAuthenticator) ClearTokenCookies(c echo.Context) {
c.SetCookie(clearAuthCookie(DataCookieName, false, a.cookieDomain))
c.SetCookie(clearAuthCookie(SignatureCookieName, true, a.cookieDomain))
}
func (a *baseAuthenticator) GetSigningKey() []byte {
return a.signingKey
}
func (a *baseAuthenticator) GetMethod() Method {
return a.authMethod
}
func (a *baseAuthenticator) GetCookieDomain() string {
return a.cookieDomain
}
func (a *baseAuthenticator) GetTokenLifetime() time.Duration {
return a.tokenLifetime
}

View File

@@ -0,0 +1,43 @@
package auth
import (
"net/http"
"time"
)
type authCookieValues struct {
name string
value string
// domain string
httpOnly bool
lifetime time.Duration
}
func makeAuthCookie(values authCookieValues) *http.Cookie {
expiresAt := time.Now().Add(values.lifetime)
cookie := &http.Cookie{
Name: values.name,
Value: values.value,
Expires: expiresAt,
HttpOnly: values.httpOnly,
Path: "/",
// Domain: values.domain,
MaxAge: int(values.lifetime.Seconds()),
Secure: true,
SameSite: http.SameSiteLaxMode,
}
return cookie
}
func clearAuthCookie(name string, httpOnly bool, domain string) *http.Cookie {
vals := authCookieValues{
name: name,
value: "",
// domain: domain,
httpOnly: httpOnly,
lifetime: time.Second,
}
cookie := makeAuthCookie(vals)
return cookie
}

View File

@@ -0,0 +1,16 @@
package auth
type GithubAuthenticator struct {
baseAuthenticator
}
func NewGithubAuthenticator(cfg Config) (*GithubAuthenticator, error) {
ghAuth := &GithubAuthenticator{}
// TODO: check these
ghAuth.signingKey = cfg.SigningKey
ghAuth.tokenLifetime = cfg.TokenLifetime
ghAuth.cookieDomain = cfg.CookieDomain
ghAuth.authMethod = MethodGithub
return ghAuth, nil
}

View File

@@ -0,0 +1,14 @@
package auth
type NoneAuthenticator struct {
baseAuthenticator
}
func NewNoneAuthenticator(cfg Config) (*NoneAuthenticator, error) {
noneAuth := &NoneAuthenticator{}
noneAuth.signingKey = cfg.SigningKey
noneAuth.tokenLifetime = cfg.TokenLifetime
noneAuth.cookieDomain = cfg.CookieDomain
noneAuth.authMethod = MethodNone
return noneAuth, nil
}

View File

@@ -0,0 +1,31 @@
package auth
import (
"golang.org/x/crypto/bcrypt"
)
const DefaultBCryptCost = 14
type PasswordAuthenticator struct {
baseAuthenticator
bcryptCost int
}
func NewPasswordAuthenticator(cfg Config, bCryptCost int) (*PasswordAuthenticator, error) {
pwAuth := &PasswordAuthenticator{}
pwAuth.bcryptCost = bCryptCost
pwAuth.signingKey = cfg.SigningKey
pwAuth.tokenLifetime = cfg.TokenLifetime
pwAuth.cookieDomain = cfg.CookieDomain
pwAuth.authMethod = MethodPassword
return pwAuth, nil
}
func (a *PasswordAuthenticator) HashPassword(password []byte) ([]byte, error) {
return bcrypt.GenerateFromPassword(password, a.bcryptCost)
}
func (a *PasswordAuthenticator) VerifyHash(password []byte, hash []byte) bool {
return bcrypt.CompareHashAndPassword(hash, password) == nil
}

View File

@@ -0,0 +1,60 @@
package server
import (
"bytes"
"crypto/rand"
"crypto/rsa"
"encoding/gob"
"fmt"
"golang.org/x/crypto/ssh"
"gorm.io/gorm"
"gitlab.com/texm/shokku/internal/models"
"gitlab.com/texm/shokku/internal/server/db"
)
func Bootstrap() error {
cfg, cfgErr := LoadConfig()
if cfgErr != nil {
return fmt.Errorf("failed to load server config: %w", cfgErr)
}
var s models.ServerSecrets
s.SigningKey = []byte(generateRandomString(32))
svDb, dbErr := db.Init(cfg.DBPath)
if dbErr != nil {
return fmt.Errorf("failed to init db: %w", dbErr)
}
deleteErr := svDb.Unscoped().
Session(&gorm.Session{AllowGlobalUpdate: true}).
Delete(&models.ServerSecrets{}).
Error
if deleteErr != nil {
return fmt.Errorf("failed to delete existing keys: %w", deleteErr)
}
key, genErr := rsa.GenerateKey(rand.Reader, 4096)
if genErr != nil {
return fmt.Errorf("failed to generate private key: %w", genErr)
}
var buf bytes.Buffer
if encodeErr := gob.NewEncoder(&buf).Encode(key); encodeErr != nil {
return fmt.Errorf("failed to encode priv key: %w", encodeErr)
}
s.DokkuSSHKeyGob = buf.Bytes()
publicRsaKey, err := ssh.NewPublicKey(&key.PublicKey)
if err != nil {
return err
}
if saveErr := svDb.Save(&s).Error; saveErr != nil {
return fmt.Errorf("failed to save private key: %w", saveErr)
}
fmt.Printf("%s", bytes.TrimSpace(ssh.MarshalAuthorizedKey(publicRsaKey)))
return nil
}

View File

@@ -0,0 +1,159 @@
package commands
import (
"bufio"
"bytes"
"errors"
"github.com/texm/dokku-go"
"gitlab.com/texm/shokku/internal/server/dto"
"io"
"regexp"
"strings"
"time"
"unicode"
)
type AsyncDokkuCommand func() (*dokku.CommandOutputStream, error)
const ansiRegexP = "[\u001B\u009B][[\\]()#;?]*(?:(?:(?:[a-zA-Z\\d]*(?:;[a-zA-Z\\d]*)*)?\u0007)|(?:(?:\\d{1,4}(?:;\\d{0,4})*)?[\\dA-PRZcf-ntqry=><~]))"
var (
letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
executions = map[string]execution{}
statuses = map[string]*ExecutionStatus{}
ansiRe = regexp.MustCompile(ansiRegexP)
fiveMinutes = time.Minute * 5
readTimeout = time.Second * 5
bufSize = 4096
)
var (
ErrNotPolled = errors.New("not polled yet")
ErrNoExecution = errors.New("no such command id")
)
func sanitiseOutput(b []byte) []byte {
b = ansiRe.ReplaceAll(b, []byte{})
mapped := bytes.Map(func(r rune) rune {
if r > unicode.MaxASCII {
return -1
}
return r
}, b)
return mapped
}
func readOutput(s chan string, e chan error, r io.Reader) {
// defer close(s)
// defer close(e)
output := bytes.Buffer{}
reader := bufio.NewReader(r)
for {
buf := make([]byte, bufSize)
n, err := reader.Read(buf)
if err != nil {
e <- err
break
}
if n > 0 {
output.Write(sanitiseOutput(buf[:n]))
}
if n < bufSize {
break
}
}
if output.Len() > 0 {
s <- strings.TrimSpace(output.String())
}
}
func ReadWithTimeout(reader io.Reader, timeout time.Duration) (string, error) {
s := make(chan string)
e := make(chan error)
go readOutput(s, e, reader)
select {
case str := <-s:
return str, nil
case err := <-e:
return "", err
case <-time.After(timeout):
return "", nil
}
}
func toOutputLines(lines []string, outputType string, pollTime time.Time) []dto.OutputLine {
output := make([]dto.OutputLine, len(lines))
for i, line := range lines {
output[i] = dto.OutputLine{Msg: line, Type: outputType, PolledAt: pollTime}
}
return output
}
func getExecutionStatus(id string) *ExecutionStatus {
status, ok := statuses[id]
if !ok {
return &ExecutionStatus{}
}
return status
}
func PollStatuses() {
for id, status := range statuses {
if !status.Finished {
continue
}
if time.Since(status.FinishedAt) > fiveMinutes {
delete(statuses, id)
}
}
for id, exec := range executions {
if exec.output == nil {
continue
}
status := getExecutionStatus(id)
pollTime := time.Now()
stdout, stdoutErr := ReadWithTimeout(exec.output.Stdout, readTimeout)
if stdoutErr != nil {
status.StdoutReadError = stdoutErr
}
if stdout != "" {
stdoutLines := strings.Split(stdout, "\n")
output := toOutputLines(stdoutLines, "stdout", pollTime)
// status.Stdout = append(status.Stdout, stdoutLines...)
status.CombinedOutput = append(status.CombinedOutput, output...)
}
stderr, stderrErr := ReadWithTimeout(exec.output.Stderr, readTimeout)
if stderrErr != nil {
status.StderrReadError = stderrErr
}
if stderr != "" {
stderrLines := strings.Split(stderr, "\n")
// status.Stderr = append(status.Stderr, stderrLines...)
output := toOutputLines(stderrLines, "stderr", pollTime)
status.CombinedOutput = append(status.CombinedOutput, output...)
}
status.StreamError = exec.output.Error
if errors.Is(stdoutErr, io.EOF) {
delete(executions, id)
status.Finished = true
status.FinishedAt = time.Now()
if exec.callback != nil {
status.CallbackError = exec.callback()
}
}
statuses[id] = status
}
time.Sleep(time.Second)
PollStatuses()
}

View File

@@ -0,0 +1,72 @@
package commands
import (
"github.com/texm/dokku-go"
"gitlab.com/texm/shokku/internal/server/dto"
"math/rand"
"time"
)
type CallbackFunc func() error
type execution struct {
Id string
output *dokku.CommandOutputStream
callback CallbackFunc
error error
}
type ExecutionStatus struct {
CombinedOutput []dto.OutputLine
Finished bool
FinishedAt time.Time
// TODO: this smells
CallbackError error
StreamError error
StdoutReadError error
StderrReadError error
}
func generateCommandExecutionId() string {
b := make([]rune, 16)
for i := range b {
b[i] = letterRunes[rand.Intn(len(letterRunes))]
}
return string(b)
}
func RequestExecution(cmd AsyncDokkuCommand, callback CallbackFunc) string {
id := generateCommandExecutionId()
exec := execution{
Id: id,
callback: callback,
}
executions[id] = exec
go func(exec execution, id string) {
stream, err := cmd()
exec.output = stream
if err != nil {
exec.error = err
}
executions[id] = exec
}(exec, id)
return id
}
func GetExecutionStatus(id string) (*ExecutionStatus, error) {
status, ok := statuses[id]
if !ok {
_, exists := executions[id]
if exists {
return nil, ErrNotPolled
}
return nil, ErrNoExecution
}
return status, nil
}

25
internal/server/config.go Normal file
View File

@@ -0,0 +1,25 @@
package server
import (
"context"
"github.com/sethvargo/go-envconfig"
)
type Config struct {
DebugMode bool `env:"DEBUG_MODE,default=false"`
Host string `env:"HOST,default=0.0.0.0"`
Port string `env:"PORT,default=5330"`
DokkuSSHHost string `env:"DOKKU_SSH_HOST,default=127.0.0.1"`
DokkuSSHPort string `env:"DOKKU_SSH_PORT,default=22"`
DBPath string `env:"DB_PATH,default=/data/shokku.db"`
AuthTokenLifetimeMinutes int `env:"TOKEN_LIFETIME_MINS,default=15"`
}
func LoadConfig() (Config, error) {
ctx := context.Background()
var cfg Config
return cfg, envconfig.Process(ctx, &cfg)
}

View File

@@ -0,0 +1,38 @@
package db
import (
"github.com/glebarez/sqlite"
"gitlab.com/texm/shokku/internal/models"
"gorm.io/gorm"
)
func Init(dsn string) (*gorm.DB, error) {
dbCfg := &gorm.Config{
Logger: Logger{},
}
/*if cfg.DebugMode == false {
dbCfg.Logger = dbCfg.Logger.LogMode(logger.Silent)
}*/
db, err := gorm.Open(sqlite.Open(dsn), dbCfg)
if err != nil {
return nil, err
}
err = db.AutoMigrate(
&models.Server{},
&models.ServerSecrets{},
&models.App{},
&models.Service{},
&models.User{},
&models.SSHKey{},
&models.GithubApp{},
&models.AppSetupConfig{},
)
if err != nil {
return nil, err
}
return db, nil
}

View File

@@ -0,0 +1,70 @@
package db
import (
"context"
"fmt"
"time"
"github.com/rs/zerolog"
"gorm.io/gorm/logger"
)
var durationUnits = map[time.Duration]string{
time.Nanosecond: "elapsed_ns",
time.Microsecond: "elapsed_us",
time.Millisecond: "elapsed_ms",
time.Second: "elapsed",
time.Minute: "elapsed_min",
time.Hour: "elapsed_hr",
}
type Logger struct{}
func (l Logger) LogMode(logger.LogLevel) logger.Interface {
return l
}
func (l Logger) Error(ctx context.Context, msg string, opts ...interface{}) {
zerolog.Ctx(ctx).Error().Msg(fmt.Sprintf(msg, opts...))
}
func (l Logger) Warn(ctx context.Context, msg string, opts ...interface{}) {
zerolog.Ctx(ctx).Warn().Msg(fmt.Sprintf(msg, opts...))
}
func (l Logger) Info(ctx context.Context, msg string, opts ...interface{}) {
zerolog.Ctx(ctx).Info().Msg(fmt.Sprintf(msg, opts...))
}
func (l Logger) Trace(ctx context.Context, begin time.Time, f func() (string, int64), err error) {
zl := zerolog.Ctx(ctx)
var event *zerolog.Event
if err != nil {
event = zl.Debug()
} else {
event = zl.Trace()
}
durationKey, found := durationUnits[zerolog.DurationFieldUnit]
if !found {
zl.Error().
Dur("zerolog.DurationFieldUnit", zerolog.DurationFieldUnit).
Msg("unknown value for zerolog.DurationFieldUnit")
durationKey = "elapsed_"
}
event.Dur(durationKey, time.Since(begin))
sql, rows := f()
if sql != "" {
event.Str("sql", sql)
}
if rows > -1 {
event.Int64("rows", rows)
}
event.Send()
return
}

View File

@@ -0,0 +1,28 @@
package dokku
import (
"crypto/rsa"
"github.com/texm/dokku-go"
"golang.org/x/crypto/ssh"
)
type Config struct {
DebugMode bool
PrivateKey *rsa.PrivateKey
Host string
Port string
}
func Init(cfg Config) (*dokku.SSHClient, error) {
dCfg := &dokku.SSHClientConfig{
Host: cfg.Host,
Port: cfg.Port,
PrivateKey: cfg.PrivateKey,
// TODO: supply host key / actually check it
HostKeyCallback: ssh.InsecureIgnoreHostKey(),
}
return dokku.NewSSHClient(dCfg)
}

View File

@@ -0,0 +1,158 @@
package dokku
import (
"errors"
"fmt"
"github.com/rs/zerolog/log"
"github.com/texm/dokku-go"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/models"
"strings"
)
// todo: deduplicate from api/services
var (
dokkuErrPrefix = "! "
serviceTypes = []string{"redis", "postgres", "mysql", "mongo"}
)
const FilteredApp = "shokku"
func SyncState(e *env.Env) {
syncApps(e)
syncServices(e)
}
func getServiceList(e *env.Env, serviceType string) ([]string, error) {
listCmd := fmt.Sprintf("%s:list --quiet", serviceType)
out, err := e.Dokku.Exec(listCmd)
if err != nil {
return nil, err
}
if strings.HasPrefix(out, dokkuErrPrefix) {
return []string{}, nil
}
return strings.Split(out, "\n"), nil
}
func syncApps(e *env.Env) {
logger := log.With().Str("dokku_sync", "apps").Logger()
apps, listErr := e.Dokku.ListApps()
if listErr != nil {
if !errors.Is(listErr, dokku.NoDeployedAppsError) {
logger.Error().
Err(listErr).
Msg("Failed to get dokku apps")
return
}
}
var filtered []string
for _, name := range apps {
if name != FilteredApp {
filtered = append(filtered, name)
}
}
var dbApps []models.App
if dbErr := e.DB.Find(&dbApps).Error; dbErr != nil {
logger.Error().
Err(dbErr).
Msg("Failed to query db apps")
return
}
appMap := map[string]bool{}
for _, name := range filtered {
var dbApp models.App
res := e.DB.Limit(1).
Where(&models.App{Name: name}).
FirstOrCreate(&dbApp)
if res.Error != nil {
logger.Error().
Err(res.Error).
Str("app_name", name).
Msg("failed to create db app")
}
appMap[name] = true
}
for _, dbApp := range dbApps {
status, found := appMap[dbApp.Name]
if !found || !status {
if err := e.DB.Delete(&dbApp).Error; err != nil {
logger.Error().
Err(err).
Uint("id", dbApp.ID).
Str("name", dbApp.Name).
Msg("failed to clean old app")
}
continue
}
if syncErr := syncApp(e, dbApp); syncErr != nil {
logger.Error().
Err(syncErr).
Str("name", dbApp.Name).
Msg("failed to sync dokku app")
}
}
}
func syncApp(e *env.Env, dbApp models.App) error {
// TODO: sync setup info
return nil
}
func serviceKey(name, svcType string) string {
return fmt.Sprintf("%s_%s", svcType, name)
}
func syncServices(e *env.Env) {
logger := log.With().Str("dokku_sync", "services").Logger()
var dbServices []models.Service
if dbErr := e.DB.Find(&dbServices).Error; dbErr != nil {
logger.Error().
Err(dbErr).
Msg("Failed to query db services")
return
}
svcMap := map[string]bool{}
for _, serviceType := range serviceTypes {
services, err := getServiceList(e, serviceType)
if err != nil {
logger.Error().
Err(err).
Msgf("failed getting %s services", serviceType)
continue
}
for _, name := range services {
svcMap[serviceKey(name, serviceType)] = true
var dbSvc models.Service
e.DB.Where(&models.Service{Name: name, Type: serviceType}).
Limit(1).
FirstOrCreate(&dbSvc)
e.DB.Save(&dbSvc)
}
}
for _, svc := range dbServices {
key := serviceKey(svc.Name, svc.Type)
status, found := svcMap[key]
if !found || !status {
if err := e.DB.Delete(&svc).Error; err != nil {
logger.Error().
Err(err).
Uint("id", svc.ID).
Str("type", svc.Type).
Str("name", svc.Name).
Msg("failed to delete old service")
}
}
}
}

325
internal/server/dto/apps.go Normal file
View File

@@ -0,0 +1,325 @@
package dto
import (
"github.com/texm/dokku-go"
"time"
)
type GetAppOverviewRequest struct {
Name string `query:"name" validate:"appName"`
}
type GetAppOverviewResponse struct {
Name string `json:"name,omitempty"`
IsSetup bool `json:"is_setup"`
SetupMethod string `json:"setup_method"`
GitDeployBranch string `json:"git_deploy_branch"`
GitLastUpdated string `json:"git_last_updated"`
IsDeployed bool `json:"is_deployed"`
IsRunning bool `json:"is_running"`
NumProcesses int `json:"num_processes"`
CanScale bool `json:"can_scale"`
Restore bool `json:"restore"`
}
type GetAllAppsOverviewResponse struct {
Apps []GetAppOverviewResponse `json:"apps"`
}
type GetAppsListItem struct {
Name string `json:"name"`
Type string `json:"type"`
}
type GetAppsListResponse struct {
Apps []GetAppsListItem `json:"apps"`
}
type GetAppInfoRequest struct {
Name string `query:"name" validate:"appName"`
}
type DestroyAppRequest struct {
Name string `json:"name"`
}
type AppInfo struct {
Name string `json:"name"`
Directory string `json:"directory"`
DeploySource string `json:"deploy_source"`
DeploySourceMetadata string `json:"deploy_source_metadata"`
CreatedAt time.Time `json:"created_at"`
IsLocked bool `json:"is_locked"`
}
type GetAppInfoResponse struct {
Info AppInfo `json:"info"`
}
type ManageAppRequest struct {
Name string `json:"name" validate:"appName"`
}
type GetAppSetupStatusRequest struct {
Name string `json:"name" validate:"appName"`
}
type GetAppSetupStatusResponse struct {
IsSetup bool `json:"is_setup"`
Method string `json:"method"`
}
type GetAppSetupConfigRequest struct {
Name string `query:"name" validate:"appName"`
}
type GetAppSetupConfigResponse struct {
IsSetup bool `json:"is_setup"`
Method string `json:"method"`
DeploymentBranch string `json:"deployment_branch,omitempty"`
RepoURL string `json:"repo_url,omitempty"`
RepoGitRef string `json:"repo_git_ref,omitempty"`
Image string `json:"image,omitempty"`
}
type SetupAppNewRepoRequest struct {
Name string `json:"name" validate:"appName"`
DeploymentBranch string `json:"deployment_branch"`
}
type SetupAppSyncRepoRequest struct {
Name string `json:"name" validate:"appName"`
RepositoryURL string `json:"repository_url"`
GitRef string `json:"git_ref"`
}
type SetupAppPullImageRequest struct {
Name string `json:"name" validate:"appName"`
Image string `json:"image"`
}
type SetupAppUploadArchiveRequest struct {
Name string `form:"name" validate:"appName"`
}
type RenameAppRequest struct {
CurrentName string `json:"current_name" validate:"appName"`
NewName string `json:"new_name" validate:"appName"`
}
/*
methods = ["Git Push", "Git Repository", "Archive File", "Dockerfile", "Docker Image"]
options = [["deploymentBranch", "envVar"], ["repositoryURL", "gitRef"], ["file"],
["dockerfilePath", "usingBuildkit"], ["image"]]
*/
type DeployAppRequest struct {
Name string `json:"name" validate:"appName"`
Method string `json:"method" validate:"alpha"`
Options map[string]string `json:"options" validate:"alpha"`
}
type GetAppServicesRequest struct {
Name string `query:"name" validate:"appName"`
}
type GetAppDeployChecksRequest struct {
Name string `query:"name" validate:"appName"`
}
type GetAppDeployChecksResponse struct {
AllDisabled bool `json:"all_disabled"`
AllSkipped bool `json:"all_skipped"`
DisabledProcesses []string `json:"disabled_processes"`
SkippedProcesses []string `json:"skipped_processes"`
}
type SetAppDeployChecksRequest struct {
Name string `json:"name" validate:"appName"`
// enabled, disabled, skipped
State string `json:"state" validate:"alpha"`
}
type SetAppProcessDeployChecksRequest struct {
Name string `json:"name" validate:"appName"`
Process string `json:"process" validate:"processName"`
// enabled, disabled, skipped
State string `json:"state" validate:"alpha"`
}
type GetAppProcessesRequest struct {
Name string `query:"name" validate:"appName"`
}
type GetAppProcessesResponse struct {
Processes []string `json:"processes"`
}
type GetAppProcessReportRequest struct {
Name string `query:"name" validate:"appName"`
}
type AppProcessInfo struct {
Scale int `json:"scale"`
Resources dokku.ResourceSettings `json:"resources"`
}
type GetAppProcessReportResponse struct {
ResourceDefaults dokku.ResourceSettings `json:"resource_defaults"`
Processes map[string]AppProcessInfo `json:"processes"`
}
type AppResources struct {
CPU *int `json:"cpu"`
Memory *int `json:"memory"`
MemoryUnit *string `json:"memory_unit"`
}
type SetAppProcessResourcesRequest struct {
Name string `json:"name" validate:"appName"`
Process string `json:"process" validate:"processName"`
ResourceLimits AppResources `json:"limits"`
ResourceReservations AppResources `json:"reservations"`
}
type GetAppProcessScaleRequest struct {
Name string `query:"name" validate:"appName"`
}
type GetAppProcessScaleResponse struct {
ProcessScale map[string]int `json:"process_scale"`
}
type SetAppProcessScaleRequest struct {
Name string `json:"name" validate:"appName"`
Process string `json:"process" validate:"processName"`
Scale int `json:"scale" validate:"numeric"`
SkipDeploy bool `json:"skip_deploy"`
}
type GetAppDomainsReportRequest struct {
Name string `query:"name" validate:"appName"`
}
type GetAppDomainsReportResponse struct {
Domains []string `json:"domains"`
Enabled bool `json:"enabled"`
}
type SetAppDomainsEnabledRequest struct {
Name string `json:"name" validate:"appName"`
Enabled bool `json:"enabled"`
}
type GetAppLetsEncryptEnabledRequest struct {
Name string `query:"name" validate:"appName"`
}
type SetAppLetsEncryptEnabledRequest struct {
Name string `json:"name" validate:"appName"`
Enabled bool `json:"enabled"`
}
type GetAppDomainsRequest struct {
Name string `query:"name" validate:"appName"`
}
type AlterAppDomainRequest struct {
Name string `json:"name" validate:"appName"`
Domain string `json:"domain" validate:"hostname_rfc1123"`
}
type AlterNetworkRequest struct {
Network string `query:"network"`
}
type ListNetworksResponse struct {
Networks []string `json:"networks"`
}
type GetAppNetworksReportRequest struct {
Name string `query:"name" validate:"appName"`
}
type GetAppNetworksReportResponse struct {
AttachInitial string `json:"attach_initial"`
AttachPostCreate string `json:"attach_post_create"`
AttachPostDeploy string `json:"attach_post_deploy"`
BindAllInterfaces bool `json:"bind_all_interfaces"`
TLD string `json:"tld"`
WebListeners string `json:"web_listeners"`
}
type SetAppNetworksRequest struct {
Name string `query:"name" validate:"appName"`
Initial *string `json:"attach_initial"`
PostCreate *string `json:"attach_post_create"`
PostDeploy *string `json:"attach_post_deploy"`
BindAllInterfaces *bool `json:"bind_all_interfaces"`
TLD *string `json:"tld"`
}
type GetAppLogsRequest struct {
Name string `query:"name" validate:"appName"`
}
type GetAppLogsResponse struct {
Logs []string `json:"logs"`
}
type GetAppConfigRequest struct {
Name string `query:"name" validate:"appName"`
}
type GetAppConfigResponse struct {
Config map[string]string `json:"config"`
}
type SetAppConfigRequest struct {
Name string `json:"name" validate:"appName"`
// validate keys and values are alphanumeric
Config map[string]string `json:"config" validate:"dive,keys,alphanum,endkeys,alphanum"`
// Config map[string]string `json:"config"`
}
type GetAppStorageRequest struct {
Name string `query:"name" validate:"appName"`
}
type StorageMount struct {
HostDir string `json:"hostDir"`
ContainerDir string `json:"mountDir"`
IsBuildMount bool `json:"isBuildMount"`
IsRunMount bool `json:"isRunMount"`
IsDeployMount bool `json:"isDeployMount"`
}
type GetAppStorageResponse struct {
Mounts []StorageMount `json:"mounts"`
}
type AlterAppStorageRequest struct {
Name string `json:"name" validate:"appName"`
RestartApp bool `json:"restart"`
// TODO: validation for these
StorageType string `json:"selectedType"`
HostDir string `json:"hostDir" validate:"gte=2,alphanum"`
ContainerDir string `json:"mountDir"`
}
type GetAppBuilderRequest struct {
Name string `query:"name" validate:"appName"`
}
type GetAppBuilderResponse struct {
Selected string `json:"selected"`
}
type SetAppBuilderRequest struct {
Name string `json:"name" validate:"appName"`
Builder string `json:"builder" validate:"alphanum"`
}
type GetAppBuildDirectoryRequest struct {
Name string `query:"name" validate:"appName"`
}
type GetAppBuildDirectoryResponse struct {
Directory string `json:"directory"`
}
type SetAppBuildDirectoryRequest struct {
Name string `json:"name" validate:"appName"`
Directory string `json:"directory" validate:"alphanum"`
}
type ClearAppBuildDirectoryRequest struct {
Name string `query:"name" validate:"appName"`
}

View File

@@ -0,0 +1,65 @@
package dto
type PasswordLoginRequest struct {
Username string `json:"username"`
Password string `json:"password"`
TotpCode string `json:"totp"`
}
type PasswordLoginResponse struct {
Success bool `json:"success"`
NeedsTotp bool `json:"needs_totp"`
}
type GithubAuthRequest struct {
Code string `json:"code"`
RedirectURL string `json:"redirect_url"`
}
type GetGithubSetupStatus struct {
AppCreated bool `json:"created"`
}
type CreateGithubAppRequest struct {
Code string `json:"code"`
}
type CreateGithubAppResponse struct {
Slug string `json:"slug"`
}
type InstallGithubAppResponse struct {
InstallURL string `json:"install_url"`
}
type CompleteGithubSetupRequest struct {
Code string `json:"code"`
InstallationId int64 `json:"installation_id"`
}
type GetGithubAuthInfoResponse struct {
ClientID string `json:"client_id"`
}
type GenerateTotpResponse struct {
Secret string `json:"secret"`
Image string `json:"image"`
RecoveryCode string `json:"recovery_code"`
}
type ConfirmTotpRequest struct {
Secret string `json:"secret"`
Code string `json:"code"`
}
type ConfirmTotpResponse struct {
Valid bool `json:"valid"`
}
type CompletePasswordSetupRequest struct {
Username string `json:"username"`
Password string `json:"password"`
Enable2FA bool `json:"enable_2fa"`
TotpSecret string `json:"totp_secret"`
RecoveryCode string `json:"recovery_code"`
}

View File

@@ -0,0 +1,38 @@
package dto
import (
"time"
)
type GetCommandExecutionStatusRequest struct {
ExecutionID string `query:"execution_id" validate:"required"`
}
type CommandExecutionResponse struct {
ExecutionID string `json:"execution_id"`
}
type OutputLine struct {
Msg string `json:"message"`
Type string `json:"type"`
PolledAt time.Time `json:"polled_at"`
}
type CommandExecutionStatusResponse struct {
Started bool `json:"started"`
Finished bool `json:"finished"`
Success bool `json:"success"`
CombinedOutput []OutputLine `json:"output"`
}
type AppExecInProcessRequest struct {
AppName string `json:"appName" validate:"appName"`
ProcessName string `json:"processName" validate:"required"`
Command string `json:"command"`
}
type AppExecInProcessResponse struct {
Output string `json:"output"`
Error string `json:"error"`
}

117
internal/server/dto/dto.go Normal file
View File

@@ -0,0 +1,117 @@
package dto
import (
"errors"
"fmt"
"github.com/go-playground/validator/v10"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"github.com/texm/dokku-go"
"net/http"
"reflect"
"regexp"
)
// allow alphanumeric, underscores, and hyphens
func appNameCharsValidator() func(level validator.FieldLevel) bool {
r, err := regexp.Compile("\\w[\\w\\-]*")
if err != nil {
log.Fatal().Err(err).Msg("failed to compile regexp")
}
return func(fl validator.FieldLevel) bool {
if fl.Field().Kind() != reflect.String {
return false
}
str := fl.Field().String()
return r.FindString(str) == str
}
}
type requestValidator struct {
validator *validator.Validate
}
func (rv *requestValidator) Validate(i interface{}) error {
return rv.validator.Struct(i)
}
func NewRequestValidator() *requestValidator {
v := validator.New()
err := v.RegisterValidation("appNameChars", appNameCharsValidator())
if err != nil {
log.Fatal().Err(err).Msg("failed to register validator")
}
v.RegisterAlias("appName", "appNameChars,min=4,max=16")
v.RegisterAlias("processName", "appNameChars,min=2,max=16")
return &requestValidator{validator: v}
}
type RequestError struct {
err error
isBinding bool
isInvalidFormat bool
isInvalidData bool
validationErrors []validator.FieldError
}
func (r *RequestError) String() string {
return fmt.Sprintf("%+v", r.validationErrors)
}
func (r *RequestError) ToHTTP() *echo.HTTPError {
err := echo.NewHTTPError(http.StatusBadRequest).SetInternal(r.err)
if r.isBinding {
err.Message = echo.Map{"type": "binding"}
} else if r.isInvalidFormat {
err.Message = echo.Map{"type": "format"}
} else if r.isInvalidData {
fields := map[string]string{}
for _, fe := range r.validationErrors {
fields[fe.Field()] = fe.ActualTag()
}
err.Message = echo.Map{
"type": "validation",
"fields": fields,
}
} else {
err = echo.ErrInternalServerError
}
return err
}
func BindRequest(c echo.Context, r any) *RequestError {
if err := c.Bind(r); err != nil {
return &RequestError{
err: err,
isBinding: true,
}
}
if err := c.Validate(r); err != nil {
if errors, ok := err.(validator.ValidationErrors); ok {
return &RequestError{
err: err,
isInvalidData: true,
validationErrors: errors,
}
}
return &RequestError{
err: err,
isInvalidFormat: true,
}
}
return nil
}
func MaybeConvertDokkuError(err error) *echo.HTTPError {
if errors.Is(err, dokku.InvalidAppError) {
return echo.NewHTTPError(http.StatusBadRequest, "no such app")
}
if errors.Is(err, dokku.NameTakenError) {
return echo.NewHTTPError(http.StatusBadRequest, "duplicate app name")
}
return nil
}

View File

@@ -0,0 +1,114 @@
package dto
type ManageServiceRequest struct {
Name string `json:"name" validate:"appName"`
Type string `json:"type" validate:"alphanum"`
}
type GenericServiceRequest struct {
Name string `query:"name" validate:"appName"`
Type string `query:"type" validate:"alphanum"`
}
type GenericServiceCreationConfig struct {
ConfigOptions *string `json:"config-options"`
// validate inner pairs are len=2
CustomEnv *[][]string `json:"custom-env"`
Image *string `json:"image"`
ImageVersion *string `json:"image-version"`
MemoryLimit *string `json:"memory"`
Password *string `json:"password"`
RootPassword *string `json:"root-password"`
SharedMemorySize *string `json:"shm-size"`
}
type CreateGenericServiceRequest struct {
Name string `json:"name" validate:"appName"`
ServiceType string `json:"type"`
Config GenericServiceCreationConfig `json:"config"`
}
type CloneServiceRequest struct {
Name string `json:"name" validate:"appName"`
NewName string `json:"newName" validate:"appName"`
}
type ServiceInfo struct {
Name string `json:"name"`
Type string `json:"type"`
}
type ListServicesResponse struct {
Services []ServiceInfo `json:"services"`
}
type GetServiceInfoResponse struct {
Info map[string]string `json:"info"`
}
type GetServiceTypeRequest struct {
Name string `query:"name" validate:"appName"`
}
type GetServiceTypeResponse struct {
Type string `json:"type"`
}
type LinkGenericServiceToAppRequest struct {
ServiceName string `json:"service_name" validate:"appName"`
AppName string `json:"app_name" validate:"appName"`
Alias string `json:"alias"`
QueryString string `json:"query_string"`
}
type GetServiceLinkedAppsResponse struct {
Apps []string `json:"apps"`
}
type GetServiceLogsResponse struct {
Logs []string `json:"logs"`
}
type GetServiceBackupReportRequest struct {
Name string `query:"name" validate:"appName"`
}
type ServiceBackupReport struct {
AuthSet bool `json:"auth_set"`
EncryptionSet bool `json:"encryption_set"`
Bucket string `json:"bucket"`
Schedule string `json:"schedule"`
}
type GetServiceBackupReportResponse struct {
Report ServiceBackupReport `json:"report"`
}
type RunServiceBackupRequest struct {
Name string `query:"name" validate:"appName"`
}
type BackupsAuthConfig struct {
AccessKeyId string `json:"access_key_id"`
SecretKey string `json:"secret_key"`
Region string `json:"region"`
SignatureVersion string `json:"signature_version"`
EndpointUrl string `json:"endpoint_url"`
}
type SetServiceBackupsAuthRequest struct {
Name string `json:"name" validate:"appName"`
Config BackupsAuthConfig `json:"config"`
}
type SetServiceBackupsBucketRequest struct {
Name string `json:"name" validate:"appName"`
Bucket string `json:"bucket"`
}
type SetServiceBackupsScheduleRequest struct {
Name string `json:"name" validate:"appName"`
Schedule string `json:"schedule"`
}
type SetServiceBackupsEncryptionRequest struct {
Name string `json:"name" validate:"appName"`
Passphrase string `json:"passphrase"`
}

View File

@@ -0,0 +1,78 @@
package dto
import "github.com/texm/dokku-go"
type GetVersionsResponse struct {
Dokku string `json:"dokku"`
Shokku string `json:"shokku"`
}
type GetLetsEncryptStatusResponse struct {
Installed bool `json:"installed"`
Email string `json:"email"`
}
type User struct {
Name string `json:"name"`
Source string `json:"source"`
SSHKeys []string `json:"ssh_keys"`
}
type GetUsersResponse struct {
Users []User `json:"users"`
}
type GetSSHKeysResponse struct {
Keys []dokku.SSHKey `json:"keys"`
}
type GetGlobalDomainsResponse struct {
Domains []string `json:"domains"`
Enabled bool `json:"enabled"`
}
type AlterGlobalDomainRequest struct {
Domain string `json:"domain"`
}
type DeleteGlobalDomainRequest struct {
Domain string `query:"domain"`
}
type AddGitAuthRequest struct {
Host string `json:"host"`
Username string `json:"username"`
Password string `json:"password"`
}
type RemoveGitAuthRequest struct {
Host string `json:"host"`
}
type SetDockerRegistryRequest struct {
Server string `json:"server"`
Username string `json:"username"`
Password string `json:"password"`
}
type GetDockerRegistryReportResponse struct {
Server string `json:"server"`
PushOnRelease bool `json:"push_on_release"`
}
type SetEventLoggingEnabledRequest struct {
Enabled bool `json:"enabled"`
}
type GetEventLogsListResponse struct {
Events []string `json:"events"`
}
type GetEventLogsResponse struct {
Logs string `json:"logs"`
}
type PluginInfo struct {
Name string `json:"name"`
Version string `json:"version"`
Enabled bool `json:"enabled"`
Description string `json:"description"`
}
type ListPluginsResponse struct {
Plugins []PluginInfo `json:"plugins"`
}

View File

@@ -0,0 +1,6 @@
package dto
type GetSetupStatusResponse struct {
IsSetup bool `json:"is_setup"`
Method string `json:"method"`
}

View File

@@ -0,0 +1,86 @@
package github
import (
"context"
"net/http"
"github.com/bradleyfalzon/ghinstallation/v2"
gh "github.com/google/go-github/v48/github"
"github.com/rs/zerolog/log"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/models"
)
type AppClient struct {
*gh.Client
appsTransport *ghinstallation.AppsTransport
}
func GetAppClient(e *env.Env) (*AppClient, error) {
var dbApp models.GithubApp
if err := e.DB.Find(&dbApp).Error; err != nil {
return nil, err
}
transport, err := ghinstallation.NewAppsTransport(
http.DefaultTransport, dbApp.AppId, []byte(dbApp.PEM))
if err != nil {
log.Debug().Err(err).Msg("failed to create transport")
return nil, err
}
appClient := &AppClient{
Client: gh.NewClient(&http.Client{Transport: transport}),
appsTransport: transport,
}
return appClient, nil
}
type AppInstallationClient struct {
*gh.Client
}
func (c *AppClient) GetInstallationClient(id int64) *AppInstallationClient {
transport := ghinstallation.NewFromAppsTransport(c.appsTransport, id)
client := gh.NewClient(&http.Client{Transport: transport})
return &AppInstallationClient{Client: client}
}
func CompleteAppManifest(e *env.Env, code string) (*gh.AppConfig, error) {
ctx := context.Background()
client := gh.NewClient(nil)
cfg, _, ghErr := client.Apps.CompleteAppManifest(ctx, code)
if ghErr != nil {
return nil, ghErr
}
appId := cfg.GetID()
dbApp := models.GithubApp{AppId: appId}
if dbErr := e.DB.FirstOrCreate(&dbApp).Error; dbErr != nil {
log.Error().Err(dbErr).Msg("failed db lookup")
return nil, dbErr
}
dbApp.AppId = appId
dbApp.ClientId = cfg.GetClientID()
dbApp.NodeId = cfg.GetNodeID()
dbApp.Slug = cfg.GetSlug()
dbApp.PEM = cfg.GetPEM()
dbApp.ClientSecret = cfg.GetClientSecret()
dbApp.WebhookSecret = cfg.GetWebhookSecret()
// saveRes := e.DB.Where(&models.GithubApp{AppId: appId}).Save(&dbApp)
if err := e.DB.Save(&dbApp).Error; err != nil {
log.Error().Err(err).Msg("failed to save db app")
return nil, err
}
return cfg, nil
}
func (c *AppClient) GetApp(ctx context.Context) (*gh.App, error) {
app, _, err := c.Apps.Get(ctx, "")
if err != nil {
return nil, err
}
return app, nil
}

View File

@@ -0,0 +1,148 @@
package github
import (
"context"
"encoding/json"
"fmt"
gh "github.com/google/go-github/v48/github"
"github.com/rs/zerolog/log"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/models"
"gorm.io/gorm/clause"
"io"
"net/http"
"time"
)
type githubUser struct {
User models.User
SSHKeys []models.SSHKey
}
func Sync(e *env.Env) error {
if err := SyncUsersToDB(e); err != nil {
return fmt.Errorf("failed to sync github users: %w", err)
}
//if err := SyncInstallationStatus(e); err != nil {
// return fmt.Errorf()
//}
return nil
}
// SyncUsersToDB asynchronously get users in organization & add to db
func SyncUsersToDB(e *env.Env) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Second*10)
defer cancel()
client, clientErr := GetAppClient(e)
if clientErr != nil {
return fmt.Errorf("failed to get app client: %w", clientErr)
}
installs, _, installsErr := client.Apps.ListInstallations(ctx, nil)
if installsErr != nil {
return installsErr
}
var users []githubUser
for _, install := range installs {
var members []*gh.User
var err error
if install.GetAccount().GetType() == "Organization" {
insClient := client.GetInstallationClient(install.GetID())
org := install.GetAccount().GetLogin()
members, _, err = insClient.Organizations.ListMembers(ctx, org, nil)
} else {
members = append(members, install.GetAccount())
}
if err != nil {
log.Error().Err(err).
Int64("installation_id", install.GetID()).
Msg("failed to get members")
continue
}
for _, member := range members {
users = append(users, fetchUserInfo(member))
}
}
conflict := clause.OnConflict{
DoUpdates: clause.AssignmentColumns([]string{"updated_at"}),
}
for _, u := range users {
if err := e.DB.Clauses(conflict).Create(&u.User).Error; err != nil {
log.Error().Err(err).
Str("name", u.User.Name).
Msg("failed to create user")
continue
}
for _, key := range u.SSHKeys {
key.UserID = u.User.ID
if err := e.DB.Clauses(conflict).Create(&key).Error; err != nil {
log.Error().Err(err).
Str("name", u.User.Name).
Msg("failed to create user ssh key")
}
}
}
oneMinuteAgo := time.Now().Add(-time.Minute)
var deletedUsers []models.User
rUsers := e.DB.Where("updated_at < ?", oneMinuteAgo).Delete(&deletedUsers)
if rUsers.Error != nil {
log.Error().Err(rUsers.Error).Msg("failed to delete old users")
}
var deletedKeys []models.SSHKey
rKeys := e.DB.Where("updated_at < ?", oneMinuteAgo).Delete(&deletedKeys)
if rKeys.Error != nil {
log.Error().Err(rKeys.Error).Msg("failed to delete old ssh keys")
}
log.Debug().
Int("num_installations", len(installs)).
Int("synced_users", len(users)).
Int64("removed_users", rUsers.RowsAffected).
Int64("removed_keys", rKeys.RowsAffected).
Msgf("github user sync complete")
return nil
}
func fetchUserInfo(u *gh.User) githubUser {
username := u.GetLogin()
user := githubUser{
User: models.User{Name: username, Source: "github"},
}
userKeysApi := fmt.Sprintf("https://api.github.com/users/%s/keys", username)
res, reqErr := http.Get(userKeysApi)
if reqErr != nil {
log.Error().Err(reqErr).
Str("username", username).
Msg("failed to get users SSH keys")
return user
}
body, err := io.ReadAll(res.Body)
if err != nil {
log.Error().Err(err).Msg("failed to read response body")
return user
}
var keys []gh.Key
if err := json.Unmarshal(body, &keys); err != nil {
log.Error().Err(err).Msg("failed to unmarshal keys")
return user
}
user.SSHKeys = make([]models.SSHKey, len(keys))
for i, key := range keys {
user.SSHKeys[i] = models.SSHKey{
GithubID: key.GetID(),
Key: key.GetKey(),
}
}
return user
}

View File

@@ -0,0 +1,56 @@
package github
import (
"context"
gh "github.com/google/go-github/v48/github"
"github.com/rs/zerolog/log"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/models"
"golang.org/x/oauth2"
ghOAuth "golang.org/x/oauth2/github"
)
type UserClient struct {
client *gh.Client
}
type CodeExchangeParams struct {
Code string
Scopes []string
RedirectURL string
}
func ExchangeCode(ctx context.Context, e *env.Env, p CodeExchangeParams) (*UserClient, error) {
var ghApp models.GithubApp
if err := e.DB.WithContext(ctx).First(&ghApp).Error; err != nil {
log.Error().Err(err).Msg("no github app in db")
return nil, err
}
conf := &oauth2.Config{
ClientID: ghApp.ClientId,
ClientSecret: ghApp.ClientSecret,
Scopes: p.Scopes,
RedirectURL: p.RedirectURL,
Endpoint: ghOAuth.Endpoint,
}
token, err := conf.Exchange(ctx, p.Code)
if err != nil {
log.Error().Err(err).Msg("failed to exchange code for token")
return nil, err
}
tokenSource := oauth2.StaticTokenSource(token)
oauthClient := oauth2.NewClient(ctx, tokenSource)
client := gh.NewClient(oauthClient)
return &UserClient{client}, nil
}
func (u *UserClient) GetUser(ctx context.Context) (*gh.User, error) {
user, _, err := u.client.Users.Get(ctx, "")
if err != nil {
log.Error().Err(err).Msg("failed to get user")
return nil, err
}
return user, nil
}

View File

@@ -0,0 +1,75 @@
package middleware
import (
"fmt"
"github.com/labstack/echo/v4"
echoMiddleware "github.com/labstack/echo/v4/middleware"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/server/auth"
"strings"
)
const (
tokenContextKey = "user-token"
usedCookieAuthContextKey = "cookie-auth"
)
func skipDuringSetup(e *env.Env, c echo.Context) bool {
reqPath := c.Request().URL.Path
return !e.SetupCompleted && strings.HasPrefix(reqPath, "/api/setup")
}
func ProvideUserContext(e *env.Env) echo.MiddlewareFunc {
logger := middlewareLogger("userContext")
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
if skipDuringSetup(e, c) {
return next(c)
}
if err := e.Auth.SetUserContext(c, tokenContextKey); err != nil {
logger.Error().Err(err).Msg("failed to set context")
return echo.ErrInternalServerError
}
return next(c)
}
}
}
func tokenAuthSkipper(e *env.Env) echoMiddleware.Skipper {
return func(c echo.Context) bool {
return skipDuringSetup(e, c)
}
}
func TokenAuth(e *env.Env) echo.MiddlewareFunc {
config := echoMiddleware.JWTConfig{
Claims: &auth.User{},
SigningKey: e.Auth.GetSigningKey(),
TokenLookupFuncs: []echoMiddleware.ValuesExtractor{SplitTokenLookup},
TokenLookup: "header:Authorization",
ContextKey: tokenContextKey,
Skipper: tokenAuthSkipper(e),
}
return echoMiddleware.JWTWithConfig(config)
}
func SplitTokenLookup(c echo.Context) ([]string, error) {
dataCookie, err := c.Request().Cookie(auth.DataCookieName)
if err != nil {
return nil, fmt.Errorf("no data cookie: %w", err)
}
signatureCookie, err := c.Request().Cookie(auth.SignatureCookieName)
if err != nil {
return nil, fmt.Errorf("no signature cookie: %w", err)
}
c.Set(usedCookieAuthContextKey, true)
authToken := dataCookie.Value + "." + signatureCookie.Value
return []string{authToken}, nil
}
func CheckCookieAuthUsed(c echo.Context) bool {
v, ok := c.Get(usedCookieAuthContextKey).(bool)
return ok && v
}

View File

@@ -0,0 +1,10 @@
package middleware
import (
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
)
func middlewareLogger(ware string) zerolog.Logger {
return log.With().Str("middleware", ware).Logger()
}

View File

@@ -0,0 +1,31 @@
package middleware
import (
"github.com/labstack/echo/v4"
echoMiddleware "github.com/labstack/echo/v4/middleware"
"github.com/rs/zerolog"
"strings"
)
func logErrorFunc(logger zerolog.Logger, debug bool) echoMiddleware.LogErrorFunc {
return func(c echo.Context, err error, stack []byte) error {
event := logger.Error().Err(err)
if debug {
stacklines := strings.Split(string(stack), "\n")
funcName := strings.TrimRight(strings.SplitAfter(stacklines[5], "(0x")[0], "(0x")
callSite := strings.Trim(strings.SplitAfter(stacklines[6], " ")[0], "\t ")
event.Str("func", funcName)
event.Str("callsite", callSite)
}
event.Msg("Recovered from panic")
return nil
}
}
func Recover(debug bool) echo.MiddlewareFunc {
logger := middlewareLogger("recover")
cfg := echoMiddleware.RecoverConfig{
LogErrorFunc: logErrorFunc(logger, debug),
}
return echoMiddleware.RecoverWithConfig(cfg)
}

View File

@@ -0,0 +1,87 @@
package middleware
import (
"fmt"
"gitlab.com/texm/shokku/internal/server/dto"
"strings"
"time"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
)
func parseDokkuError(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
err := next(c)
if err == nil {
return nil
}
if dokkuHttpErr := dto.MaybeConvertDokkuError(err); dokkuHttpErr != nil {
log.Debug().Err(err).Msgf("converted dokku error to %s", dokkuHttpErr.Error())
return dokkuHttpErr
}
log.Error().Err(err).Str("path", c.Path()).Msg("got error")
return echo.ErrInternalServerError
}
}
func RequestLogger(debug bool) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
start := time.Now()
chainErr := next(c)
if chainErr != nil {
c.Error(chainErr)
}
req := c.Request()
res := c.Response()
if isStaticFile, ok := c.Get("static").(bool); ok && isStaticFile {
contentType := res.Header().Get("Content-Type")
isHTML := strings.HasPrefix(contentType, echo.MIMETextHTML)
if !isHTML {
return nil
}
}
n := res.Status
l := log.Info()
msg := "Success"
switch {
case n >= 500:
l = log.Error().Err(chainErr)
// logger.With(zap.Error(chainErr)).Error("Server error", fields...)
msg = "Server error"
case n >= 400:
msg = "Client error"
case n >= 300:
msg = "Redirect"
}
if debug {
l.Str("request", fmt.Sprintf("%s %s", req.Method, req.RequestURI))
l.Int("status", res.Status)
} else {
l.Str("remote_ip", c.RealIP())
l.Str("latency", time.Since(start).String())
l.Str("host", req.Host)
l.Str("request", fmt.Sprintf("%s %s", req.Method, req.RequestURI))
l.Int("status", res.Status)
l.Int64("size", res.Size)
}
id := req.Header.Get(echo.HeaderXRequestID)
if id != "" {
l.Str("request_id", id)
}
l.Msg(msg)
return nil
}
}
}

View File

@@ -0,0 +1,30 @@
package middleware
import (
"github.com/labstack/echo/v4"
echoMiddleware "github.com/labstack/echo/v4/middleware"
"net/http"
)
func Secure() echo.MiddlewareFunc {
// logger := e.Logger.Desugar()
// debug := e.DebugMode
// cfg := echomiddleware.SecureConfig{}
cfg := echoMiddleware.DefaultSecureConfig
return echoMiddleware.SecureWithConfig(cfg)
}
func CSRF() echo.MiddlewareFunc {
// we skip requests where cookie authentication was not used,
// as these are api requests - not from the browser
cfg := echoMiddleware.CSRFConfig{
CookieName: "_csrf",
CookiePath: "/",
CookieSameSite: http.SameSiteStrictMode,
Skipper: func(c echo.Context) bool {
return !CheckCookieAuthUsed(c)
},
}
return echoMiddleware.CSRFWithConfig(cfg)
}

View File

@@ -0,0 +1,59 @@
package middleware
import (
"github.com/labstack/echo/v4"
"gitlab.com/texm/shokku/internal/env"
"net/http"
"strings"
)
const (
notSetupErr = "server not setup"
invalidKeyErr = "setup key invalid"
setupKeyHeader = "X-Setup-Key"
)
func ServerSetupBlocker(e *env.Env, setupKey string) echo.MiddlewareFunc {
logger := middlewareLogger("setup")
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
reqPath := c.Request().URL.Path
if !strings.HasPrefix(reqPath, "/api") {
return next(c)
}
if reqPath == "/api/github/events" {
return next(c)
}
if reqPath == "/api/setup/status" {
return next(c)
}
isSetupRoute := strings.HasPrefix(reqPath, "/api/setup")
if e.SetupCompleted {
if isSetupRoute {
return echo.NewHTTPError(http.StatusBadRequest, "already set up")
}
return next(c)
}
providedKey := c.Request().Header.Get(setupKeyHeader)
if providedKey != setupKey {
logger.Debug().
Str("path", reqPath).
Str("provided", providedKey).
Msg("invalid setup key")
return echo.NewHTTPError(http.StatusForbidden, invalidKeyErr)
}
if isSetupRoute {
return next(c)
}
logger.Debug().Str("path", reqPath).Msg("not setup path")
return echo.NewHTTPError(http.StatusForbidden, notSetupErr)
}
}
}

View File

@@ -0,0 +1,29 @@
package middleware
import (
"github.com/labstack/echo/v4"
echomiddleware "github.com/labstack/echo/v4/middleware"
"io/fs"
"net/http"
"strings"
)
func staticFileSkipperFunc(c echo.Context) bool {
if strings.HasPrefix(c.Request().URL.Path, "/api") {
c.Set("static", false)
return true
}
c.Set("static", true)
return false
}
func StaticFiles(staticFS fs.FS) echo.MiddlewareFunc {
cfg := echomiddleware.StaticConfig{
Root: "dist",
Index: "app.html",
HTML5: true,
Filesystem: http.FS(staticFS),
Skipper: staticFileSkipperFunc,
}
return echomiddleware.StaticWithConfig(cfg)
}

13
internal/server/rand.go Normal file
View File

@@ -0,0 +1,13 @@
package server
import "math/rand"
var letterRunes = []rune("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
func generateRandomString(n int) string {
b := make([]rune, n)
for i := range b {
b[i] = letterRunes[rand.Intn(len(letterRunes))]
}
return string(b)
}

67
internal/server/router.go Normal file
View File

@@ -0,0 +1,67 @@
package server
import (
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"gitlab.com/texm/shokku/internal/server/dto"
"gitlab.com/texm/shokku/internal/server/middleware"
"io/fs"
"net/http"
)
func initRouter(debugMode bool, appFS fs.FS) *echo.Echo {
r := echo.New()
r.Debug = debugMode
r.HideBanner = true
r.Validator = dto.NewRequestValidator()
r.HTTPErrorHandler = errorHandler(debugMode)
r.Use(middleware.Recover(debugMode))
r.Use(middleware.Secure())
r.Use(middleware.RequestLogger(debugMode))
r.Use(middleware.StaticFiles(appFS))
r.Use(middleware.CSRF())
return r
}
func errorHandler(debug bool) func(error, echo.Context) {
return func(err error, c echo.Context) {
if c.Response().Committed {
return
}
httpErr, ok := err.(*echo.HTTPError)
if ok && httpErr.Internal != nil {
if herr, ok := httpErr.Internal.(*echo.HTTPError); ok {
httpErr = herr
}
} else if !ok {
httpErr = &echo.HTTPError{
Code: http.StatusInternalServerError,
Message: http.StatusText(http.StatusInternalServerError),
}
}
// Issue #1426
code := httpErr.Code
message := httpErr.Message
if m, strOk := httpErr.Message.(string); strOk {
if debug {
message = echo.Map{"message": m, "error": httpErr.Error()}
} else {
message = echo.Map{"message": m}
}
}
// Send response
if c.Request().Method == http.MethodHead { // Issue #608
err = c.NoContent(httpErr.Code)
} else {
err = c.JSON(code, message)
}
if err != nil {
log.Error().Err(err).Msg("error handler response")
}
}
}

View File

@@ -0,0 +1,47 @@
package server
import (
"bytes"
"crypto/rsa"
"database/sql"
"encoding/gob"
"errors"
"fmt"
"github.com/rs/zerolog/log"
"gitlab.com/texm/shokku/internal/models"
"gorm.io/gorm"
)
type secrets struct {
signingKey []byte
privKey *rsa.PrivateKey
}
func getServerSecrets(db *gorm.DB) (*secrets, error) {
var s models.ServerSecrets
if err := db.Find(&s).Error; err != nil && err != sql.ErrNoRows {
log.Error().
Err(err).
Msg("failed to get server secrets")
return nil, err
}
var key *rsa.PrivateKey
if len(s.DokkuSSHKeyGob) == 0 {
return nil, errors.New("no ssh key stored")
}
r := bytes.NewReader(s.DokkuSSHKeyGob)
if decodeErr := gob.NewDecoder(r).Decode(&key); decodeErr != nil {
return nil, fmt.Errorf("failed to decode priv key: %w", decodeErr)
}
if validErr := key.Validate(); validErr != nil {
return nil, fmt.Errorf("private key validation failed: %w", validErr)
}
return &secrets{
privKey: key,
signingKey: s.SigningKey,
}, nil
}

75
internal/server/server.go Normal file
View File

@@ -0,0 +1,75 @@
package server
import (
"fmt"
"github.com/labstack/echo/v4"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/server/api"
"gitlab.com/texm/shokku/internal/server/commands"
"gitlab.com/texm/shokku/internal/server/db"
"gitlab.com/texm/shokku/internal/server/dokku"
"gitlab.com/texm/shokku/internal/server/middleware"
"io/fs"
"time"
)
func New(cfg Config, appFS fs.FS) (*env.Env, error) {
e := env.New(cfg.DebugMode)
e.Router = initRouter(cfg.DebugMode, appFS)
dbSess, dbErr := db.Init(cfg.DBPath)
if dbErr != nil {
return nil, fmt.Errorf("failed to init db: %w", dbErr)
}
e.DB = dbSess
serverSecrets, secretsErr := getServerSecrets(e.DB)
if secretsErr != nil {
return nil, fmt.Errorf("failed to get server secrets: %w", secretsErr)
}
dokkuCfg := dokku.Config{
DebugMode: cfg.DebugMode,
PrivateKey: serverSecrets.privKey,
Host: cfg.DokkuSSHHost,
Port: cfg.DokkuSSHPort,
}
dokkuClient, dokkuErr := dokku.Init(dokkuCfg)
if dokkuErr != nil {
return nil, fmt.Errorf("failed to init dokku client: %w", dokkuErr)
}
e.Dokku = dokkuClient
serverState, stateErr := initServerState(e)
if stateErr != nil {
return nil, fmt.Errorf("failed to get setup state: %w", stateErr)
}
authCfg := initAuthConfig{
SigningKey: serverSecrets.signingKey,
TokenLifetime: time.Minute * time.Duration(cfg.AuthTokenLifetimeMinutes),
Method: serverState.AuthMethod,
DebugMode: cfg.DebugMode,
IsSetup: serverState.IsSetup,
}
authn, authErr := initAuthenticator(authCfg)
if authErr != nil {
return nil, fmt.Errorf("failed to init authenticator: %w", authErr)
}
e.Auth = authn
if !e.SetupCompleted {
e.Router.Use(middleware.ServerSetupBlocker(e, serverState.SetupKey))
}
api.RegisterRoutes(e, []echo.MiddlewareFunc{
middleware.TokenAuth(e),
middleware.ProvideUserContext(e),
})
dokku.SyncState(e)
go commands.PollStatuses()
return e, nil
}

72
internal/server/setup.go Normal file
View File

@@ -0,0 +1,72 @@
package server
import (
"fmt"
"github.com/rs/zerolog/log"
"gitlab.com/texm/shokku/internal/env"
"gitlab.com/texm/shokku/internal/models"
"gitlab.com/texm/shokku/internal/server/auth"
"gitlab.com/texm/shokku/internal/server/github"
"time"
)
func maybeSyncState(e *env.Env, state *models.Server) error {
if state.AuthMethod == auth.MethodGithub {
timeSinceLast := time.Since(state.LastSync)
needsSync := timeSinceLast > 6*time.Hour
if !needsSync {
return nil
}
if err := github.SyncUsersToDB(e); err != nil {
return fmt.Errorf("failed to sync github state: %w", err)
}
}
state.LastSync = time.Now()
if err := e.DB.Save(state).Error; err != nil {
return fmt.Errorf("failed to update last sync time")
}
return nil
}
func initServerState(e *env.Env) (*models.Server, error) {
var state models.Server
if err := e.DB.FirstOrCreate(&state).Error; err != nil {
log.Error().
Err(err).
Msg("failed to get state")
return nil, err
}
log.Debug().
Bool("is_setup", state.IsSetup).
Str("auth_method", string(state.AuthMethod)).
Time("last_sync", state.LastSync).
Msg("server setup state")
e.SetupCompleted = state.IsSetup
if state.IsSetup {
if syncErr := maybeSyncState(e, &state); syncErr != nil {
return nil, syncErr
}
return &state, nil
}
if state.SetupKey == "" {
state.SetupKey = generateRandomString(16)
if err := e.DB.Save(&state).Error; err != nil {
log.Error().
Err(err).
Msg("failed to update setup key")
return nil, err
}
}
log.Info().
Str("setup_key", state.SetupKey).
Msg("running in setup mode")
return &state, nil
}

View File

@@ -0,0 +1,35 @@
package server
import (
"github.com/gorilla/websocket"
"github.com/labstack/echo/v4"
"github.com/rs/zerolog/log"
"net/http"
)
type wsErrorFunc func(w http.ResponseWriter, r *http.Request, status int, reason error)
func createWSCheckOriginFunc(origin string) func(r *http.Request) bool {
return func(req *http.Request) bool {
log.Debug().
Str("origin", req.Header.Get("Origin")).
Str("host", origin).
Msg("ws check origin")
return true
}
}
func createWSErrorFunc(e *echo.Echo) wsErrorFunc {
return func(w http.ResponseWriter, r *http.Request, status int, reason error) {
err := echo.NewHTTPError(status, reason)
c := e.NewContext(r, w)
e.HTTPErrorHandler(err, c)
}
}
func initWebsocketUpgrader(cfg Config, router *echo.Echo) websocket.Upgrader {
return websocket.Upgrader{
CheckOrigin: createWSCheckOriginFunc(cfg.Host),
Error: createWSErrorFunc(router),
}
}