├── .gitignore ├── .vscode └── settings.json ├── Makefile ├── README.md ├── github-actions └── release.yml ├── go.mod ├── go.sum ├── main.go ├── pkg ├── config │ └── config.go ├── data │ ├── apps.go │ ├── auth.go │ ├── jobs.go │ ├── logs.go │ ├── models.go │ ├── projects.go │ ├── templates.go │ └── traefik.go ├── dockerctrl │ ├── client.go │ ├── container.go │ ├── helpers.go │ ├── mappers.go │ ├── models.go │ └── network.go ├── runner │ └── runner.go ├── util │ ├── array.go │ ├── encoding.go │ ├── json.go │ ├── random.go │ └── security.go └── web │ ├── api.go │ ├── appsApi.go │ ├── auth.go │ ├── containerApi.go │ ├── fileServer.go │ ├── helpers.go │ ├── jobsApi.go │ ├── models.go │ ├── networkApi.go │ ├── projectApi.go │ └── templateApi.go ├── public ├── assets │ └── index-674504cd.js ├── index.html └── vite.svg ├── templates └── traefik.json └── web ├── .eslintrc.cjs ├── .gitignore ├── index.html ├── package-lock.json ├── package.json ├── public └── vite.svg ├── src ├── App.tsx ├── api │ ├── api.ts │ └── auth.api.ts ├── components │ ├── FieldValue.tsx │ ├── Layout.tsx │ ├── Login.tsx │ ├── Navbar.tsx │ └── form │ │ ├── formContainer.tsx │ │ ├── formFieldWrapper.tsx │ │ ├── formInput.tsx │ │ ├── formSelect.tsx │ │ └── formTextarea.tsx ├── features │ ├── apps │ │ ├── AppDetails.tsx │ │ ├── Apps.tsx │ │ ├── NewApp.tsx │ │ ├── apps.api.ts │ │ └── apps.types.ts │ ├── containers │ │ ├── ContainerDetails.tsx │ │ ├── ContainerForm.tsx │ │ ├── Containers.tsx │ │ ├── NewContainer.tsx │ │ ├── containers.api.ts │ │ ├── containers.helpers.ts │ │ └── containers.types.ts │ ├── jobs │ │ ├── JobDetails.tsx │ │ ├── Jobs.tsx │ │ ├── NewJob.tsx │ │ ├── jobs.api.ts │ │ └── jobs.types.ts │ ├── networks │ │ ├── NetworkDetails.tsx │ │ ├── Networks.tsx │ │ ├── NewNetwork.tsx │ │ ├── networks.api.ts │ │ └── networks.types.ts │ ├── projects │ │ ├── NewProject.tsx │ │ ├── ProjectDetails.tsx │ │ ├── Projects.tsx │ │ ├── projects.api.ts │ │ └── projects.types.ts │ ├── settings │ │ ├── Settings.tsx │ │ ├── settings.api.ts │ │ └── settings.models.ts │ └── templates │ │ ├── NewTemplate.tsx │ │ ├── TemplateDetails.tsx │ │ ├── Templates.tsx │ │ ├── templates.api.ts │ │ └── templates.types.ts ├── hooks │ ├── index.ts │ ├── useDefaultMutation.ts │ └── useDefaultToast.ts ├── main.tsx ├── theme.ts ├── types │ ├── auth.types.ts │ └── index.ts ├── util │ ├── dayjs.ts │ └── strings.ts └── vite-env.d.ts ├── tsconfig.json ├── tsconfig.node.json └── vite.config.ts /.gitignore: -------------------------------------------------------------------------------- 1 | # Binaries for programs and plugins 2 | *.exe 3 | *.exe~ 4 | *.dll 5 | *.so 6 | *.dylib 7 | 8 | # Test binary, built with `go test -c` 9 | *.test 10 | 11 | # Output of the go coverage tool, specifically when used with LiteIDE 12 | *.out 13 | 14 | # Dependency directories (remove the comment below to include it) 15 | # vendor/ 16 | 17 | web/node_modules/ 18 | server/public/ 19 | bin/ 20 | **/*.db 21 | tmp/ 22 | -------------------------------------------------------------------------------- /.vscode/settings.json: -------------------------------------------------------------------------------- 1 | { 2 | "eslint.workingDirectories": ["./web"] 3 | } -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | build-web: 2 | echo "Building web..." 3 | cd web && npm i && npm run build 4 | rm -rf public 5 | mkdir -p public 6 | cp -r web/dist/* public 7 | 8 | build-server: 9 | echo "Building server..." 10 | go build -o ./bin/vesa 11 | 12 | build: build-web build-server 13 | 14 | build-linux: 15 | echo "Building for linux..." 16 | GOOS=linux GOARCH=amd64 make build 17 | 18 | run-web: 19 | echo "Starting dev..." 20 | cd web && npm run dev 21 | 22 | run-server: 23 | echo "Starting server..." 24 | go run . 25 | 26 | host-bin: 27 | python3 -m servefile ./bin/vesa & ngrok http 8080 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # vesa 2 | Very Easy Sys Admin - deploy projects to a VPS without having 153948 years of sys admin experience 3 | 4 | ## About 5 | This is an alternative to docker-compose with a GUI. Why? Because I don't want to ssh into my server every time I want to make a small update to my side project. 6 | 7 | ## Features 8 | - Create and manage docker containers, networks, and volumes with a web interface 9 | - Create custom templates to launch containers with predefined settings 10 | - Create templates form your existing containers 11 | - Github action to deploy automatically 12 | - Point domains to any container (or a separate service) 13 | - Get SSL certificates from LetsEncrypt 14 | - Ability to use custom docker registry (or host it yourself) 15 | 16 | ## Setup 17 | 18 | - [Install go](https://go.dev/doc/install) 19 | - Run `go install github.com/nerijusdu/vesa@latest` 20 | - Run `vesa --init` to initialize the app for the first time (running this command multiple times will rewrite the current config) 21 | - Run `vesa` to start the server 22 | 23 | ### Auto start on boot 24 | To auto start vesa when your server boots up create a systemd service file in `/etc/systemd/system/vesa.service` with the following content: 25 | ```ini 26 | [Unit] 27 | Description=Vesa App Service 28 | 29 | [Service] 30 | Environment=HOME=/home/YOUR_USERNAME 31 | ExecStart=/home/YOUR_USERNAME/go/bin/vesa 32 | 33 | [Install] 34 | WantedBy=default.target 35 | ``` 36 | 37 | Replace `YOUR_USERNAME` with your username. `ExecStart` points to go binary location. 38 | 39 | Then run the following commands: 40 | ```bash 41 | sudo systemctl daemon-reload 42 | sudo systemctl enable vesa 43 | sudo systemctl start vesa 44 | ``` 45 | 46 | ### Setup github actions releases 47 | To create easy releases using github actions 48 | - Create an API client through web interface (Settings -> Client authentication) 49 | - Copy files from `github-actions` folder to your projects `.github/workflows` folder 50 | - Open `release.yml` file and update environment variables and required secrets to your github repository. 51 | 52 | ## Troubleshooting 53 | 54 | ### If traefik times out while connecting to host services: 55 | - Allow docker to access local service with `sudo ufw allow from 172.22.0.0/16 to 172.17.0.1` 56 | 57 | ### I forgot my password 58 | - Run `vesa --init`, this will overwrite previous config 59 | 60 | 61 | ## TODO 62 | 63 | Docker: 64 | - [ ] Manage volumes 65 | - [ ] Test docker hub auth 66 | 67 | Other: 68 | - [X] Cron jobs 69 | - [ ] Add https without redirect entrypoint 70 | - [ ] Script runner? 71 | - [ ] Create auto-boot service from CLI? 72 | - [ ] Better UI 73 | - [ ] CLI tool to setup github actions 74 | - [ ] Secret manager 75 | - [ ] Is it possible to use vesa in container? 76 | 77 | Bugs: 78 | 79 | -------------------------------------------------------------------------------- /github-actions/release.yml: -------------------------------------------------------------------------------- 1 | name: Vesa release 2 | 3 | on: 4 | workflow_dispatch: 5 | # push: # uncomment this to trigger deployment on push 6 | # branches: 7 | # - main 8 | 9 | env: 10 | TEMPLATE_ID: TODO - id of your vesa template 11 | API_URL: TODO - your vesa url 12 | REGISTRY_URL: TODO - url of your docker registry 13 | REGISTRY_USER: TODO - username of your docker registry 14 | VESA_CLIENT_ID: TODO - vesa client id (from ~/.vesa/config.json) 15 | IMAGE_NAME: TODO - how you want to call this image 16 | TAG: latest 17 | 18 | # Required secrets: 19 | # REGISTRY_PASS - docker registry password 20 | # VESA_CLIENT_SECRET - vesa client secret (from ~/.vesa/config.json) 21 | 22 | jobs: 23 | push: 24 | runs-on: ubuntu-latest 25 | steps: 26 | - uses: actions/checkout@v2 27 | 28 | - name: Build image 29 | run: docker build . --file Dockerfile --tag $IMAGE_NAME 30 | 31 | - uses: docker/login-action@v1 32 | with: 33 | registry: ${{ env.REGISTRY_URL }} 34 | username: ${{ env.REGISTRY_USER }} 35 | password: ${{ secrets.REGISTRY_PASS }} 36 | 37 | - name: Push image to container registry 38 | run: | 39 | IMAGE_ID=$REGISTRY_URL/$IMAGE_NAME 40 | 41 | echo IMAGE_ID=$IMAGE_ID 42 | echo TAG=$TAG 43 | 44 | docker tag $IMAGE_NAME $IMAGE_ID:$TAG 45 | docker push $IMAGE_ID:$TAG 46 | - name: Use latest docker image 47 | uses: distributhor/workflow-webhook@v3 48 | with: 49 | webhook_url: ${{ env.API_URL }}/api/templates/${{ env.TEMPLATE_ID }}/update?tag=${{ env.TAG }} 50 | webhook_auth_type: bearer 51 | webhook_auth: ${{ secrets.VESA_CLIENT_SECRET }} 52 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/nerijusdu/vesa 2 | 3 | go 1.22.0 4 | 5 | toolchain go1.22.5 6 | 7 | require ( 8 | github.com/docker/docker v20.10.22+incompatible 9 | github.com/docker/go-connections v0.5.0 10 | github.com/go-chi/chi v1.5.4 11 | github.com/go-chi/chi/v5 v5.0.8 12 | github.com/go-chi/cors v1.2.1 13 | github.com/go-chi/oauth v0.0.0-20210913085627-d937e221b3ef 14 | github.com/go-playground/validator/v10 v10.22.1 15 | ) 16 | 17 | require ( 18 | github.com/atotto/clipboard v0.1.4 // indirect 19 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect 20 | github.com/catppuccin/go v0.2.0 // indirect 21 | github.com/charmbracelet/bubbles v0.20.0 // indirect 22 | github.com/charmbracelet/bubbletea v1.1.0 // indirect 23 | github.com/charmbracelet/lipgloss v0.13.0 // indirect 24 | github.com/charmbracelet/x/ansi v0.2.3 // indirect 25 | github.com/charmbracelet/x/exp/strings v0.0.0-20240722160745-212f7b056ed0 // indirect 26 | github.com/charmbracelet/x/term v0.2.0 // indirect 27 | github.com/dustin/go-humanize v1.0.1 // indirect 28 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect 29 | github.com/gabriel-vasile/mimetype v1.4.3 // indirect 30 | github.com/go-co-op/gocron/v2 v2.12.3 // indirect 31 | github.com/jonboulle/clockwork v0.4.0 // indirect 32 | github.com/lucasb-eyer/go-colorful v1.2.0 // indirect 33 | github.com/mattn/go-isatty v0.0.20 // indirect 34 | github.com/mattn/go-localereader v0.0.1 // indirect 35 | github.com/mattn/go-runewidth v0.0.16 // indirect 36 | github.com/mitchellh/hashstructure/v2 v2.0.2 // indirect 37 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect 38 | github.com/muesli/cancelreader v0.2.2 // indirect 39 | github.com/muesli/termenv v0.15.3-0.20240618155329-98d742f6907a // indirect 40 | github.com/rivo/uniseg v0.4.7 // indirect 41 | github.com/robfig/cron/v3 v3.0.1 // indirect 42 | golang.org/x/sync v0.8.0 // indirect 43 | ) 44 | 45 | require ( 46 | github.com/Microsoft/go-winio v0.6.0 // indirect 47 | github.com/charmbracelet/huh v0.6.0 48 | github.com/docker/distribution v2.8.1+incompatible // indirect 49 | github.com/docker/go-units v0.5.0 // indirect 50 | github.com/go-playground/locales v0.14.1 // indirect 51 | github.com/go-playground/universal-translator v0.18.1 // indirect 52 | github.com/gofrs/uuid v4.0.0+incompatible // indirect 53 | github.com/gogo/protobuf v1.3.2 // indirect 54 | github.com/google/go-cmp v0.6.0 // indirect 55 | github.com/google/uuid v1.6.0 56 | github.com/leodido/go-urn v1.4.0 // indirect 57 | github.com/moby/term v0.0.0-20221205130635-1aeaba878587 // indirect 58 | github.com/morikuni/aec v1.0.0 // indirect 59 | github.com/opencontainers/go-digest v1.0.0 // indirect 60 | github.com/opencontainers/image-spec v1.0.2 // indirect 61 | github.com/pkg/errors v0.9.1 // indirect 62 | github.com/sirupsen/logrus v1.9.0 // indirect 63 | github.com/stretchr/testify v1.9.0 // indirect 64 | golang.org/x/crypto v0.27.0 65 | golang.org/x/exp v0.0.0-20240909161429-701f63a606c0 66 | golang.org/x/mod v0.21.0 // indirect 67 | golang.org/x/net v0.29.0 // indirect 68 | golang.org/x/sys v0.25.0 // indirect 69 | golang.org/x/text v0.18.0 // indirect 70 | golang.org/x/time v0.3.0 // indirect 71 | golang.org/x/tools v0.25.0 // indirect 72 | gopkg.in/yaml.v2 v2.4.0 73 | gotest.tools/v3 v3.4.0 // indirect 74 | ) 75 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "embed" 5 | 6 | "github.com/nerijusdu/vesa/pkg/config" 7 | "github.com/nerijusdu/vesa/pkg/data" 8 | "github.com/nerijusdu/vesa/pkg/dockerctrl" 9 | "github.com/nerijusdu/vesa/pkg/runner" 10 | "github.com/nerijusdu/vesa/pkg/web" 11 | ) 12 | 13 | //go:embed public/* 14 | var content embed.FS 15 | 16 | //go:embed templates/* 17 | var defaultTempaltes embed.FS 18 | 19 | func main() { 20 | c := config.NewConfig() 21 | proj := data.NewProjectsRepository() 22 | templ := data.NewTemplateRepository(defaultTempaltes, c) 23 | auth := data.NewAuthRepository() 24 | apps := data.NewAppsRepository() 25 | traefik := data.NewTraefikRepository() 26 | jobs := data.NewJobsRepository() 27 | logs := data.NewLogsRepository() 28 | ctrl, err := dockerctrl.NewDockerCtrlClient(auth) 29 | if err != nil { 30 | panic(err) 31 | } 32 | defer ctrl.Close() 33 | 34 | existingJobs, err := jobs.GetJobs() 35 | if err != nil { 36 | panic(err) 37 | } 38 | 39 | runner := runner.NewJobRunner(logs, existingJobs) 40 | 41 | api := web.NewVesaApi(web.VesaApiConfig{ 42 | Config: c, 43 | DockerCtrl: ctrl, 44 | Projects: proj, 45 | Templates: templ, 46 | Apps: apps, 47 | Traefik: traefik, 48 | Jobs: jobs, 49 | Logs: logs, 50 | Runner: runner, 51 | Auth: auth, 52 | StaticContent: content, 53 | }) 54 | 55 | api.ServeHTTP() 56 | } 57 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/charmbracelet/huh" 8 | "github.com/nerijusdu/vesa/pkg/util" 9 | ) 10 | 11 | type Config struct { 12 | Port string 13 | JWTSecret string 14 | UserName string 15 | Password string 16 | UserEmail string 17 | EnableDashboard bool 18 | Clients []Client 19 | } 20 | 21 | type Client struct { 22 | ID string 23 | Secret string 24 | } 25 | 26 | var configFile = "config.json" 27 | 28 | func initConfig() *Config { 29 | values := &Config{ 30 | JWTSecret: util.GenerateRandomString(32), 31 | Clients: []Client{}, 32 | EnableDashboard: false, 33 | } 34 | 35 | form := huh.NewForm( 36 | huh.NewGroup( 37 | huh.NewInput(). 38 | Title("Port"). 39 | Description("What port to run web interface on?"). 40 | Placeholder("8989"). 41 | Value(&values.Port), 42 | huh.NewInput(). 43 | Title("Username"). 44 | Description("Username to login through web interface"). 45 | Placeholder("admin"). 46 | Validate(huh.ValidateNotEmpty()). 47 | Value(&values.UserName), 48 | huh.NewInput(). 49 | Title("Password"). 50 | Description("Password to login through web interface"). 51 | Placeholder("mysupersecretpassword"). 52 | EchoMode(huh.EchoModePassword). 53 | Validate(huh.ValidateNotEmpty()). 54 | Value(&values.Password), 55 | huh.NewInput(). 56 | Title("Email"). 57 | Description("Email to be used for generating SSL certificates with LetsEncrypt"). 58 | Placeholder("email@example.com"). 59 | Validate(func(val string) error { 60 | err := huh.ValidateNotEmpty()(val) 61 | if err != nil { 62 | return err 63 | } 64 | 65 | return util.ValidateEmail(val) 66 | }). 67 | Value(&values.UserEmail), 68 | ), 69 | ) 70 | 71 | err := form.Run() 72 | if err != nil { 73 | panic(err) 74 | } 75 | 76 | if values.Port == "" { 77 | values.Port = "8989" 78 | } 79 | 80 | values.Password, err = util.HashPassword(values.Password) 81 | if err != nil { 82 | panic(err) 83 | } 84 | 85 | return values 86 | } 87 | 88 | func NewConfig() *Config { 89 | isInit := len(os.Args) > 1 && os.Args[1] == "--init" 90 | changed := false 91 | var c *Config 92 | var err error 93 | 94 | if util.FileExists(configFile) { 95 | c, err = util.ReadFile[Config](configFile) 96 | if err != nil { 97 | panic(err) 98 | } 99 | } 100 | 101 | if isInit { 102 | newConfig := initConfig() 103 | if c != nil { 104 | newConfig.Clients = c.Clients 105 | } 106 | c = newConfig 107 | changed = true 108 | } 109 | 110 | if c == nil { 111 | panic("Config is not found, please run with --init flag") 112 | } 113 | 114 | if changed { 115 | err = util.WriteFile(c, configFile) 116 | if err != nil { 117 | panic(err) 118 | } 119 | } 120 | 121 | return c 122 | } 123 | 124 | func AddClient(c *Config, id, secret string) (*Config, error) { 125 | for _, client := range c.Clients { 126 | if client.ID == id { 127 | return c, fmt.Errorf("Client with this ID already exists") 128 | } 129 | } 130 | c.Clients = append(c.Clients, Client{ID: id, Secret: secret}) 131 | err := util.WriteFile(c, configFile) 132 | return c, err 133 | } 134 | 135 | func RemoveClient(c *Config, id string) (*Config, error) { 136 | for i, client := range c.Clients { 137 | if client.ID == id { 138 | c.Clients = append(c.Clients[:i], c.Clients[i+1:]...) 139 | err := util.WriteFile(c, configFile) 140 | return c, err 141 | } 142 | } 143 | return c, nil 144 | } 145 | -------------------------------------------------------------------------------- /pkg/data/apps.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/google/uuid" 7 | "github.com/nerijusdu/vesa/pkg/util" 8 | ) 9 | 10 | type AppsRepository struct { 11 | } 12 | 13 | func NewAppsRepository() *AppsRepository { 14 | if !util.FileExists("apps.json") { 15 | err := util.WriteFile(&Apps{Apps: []App{}}, "apps.json") 16 | if err != nil { 17 | panic(err) 18 | } 19 | } 20 | 21 | return &AppsRepository{} 22 | } 23 | 24 | func (p *AppsRepository) GetApps() ([]App, error) { 25 | proj, err := util.ReadFile[Apps]("apps.json") 26 | if err != nil { 27 | return nil, err 28 | } 29 | 30 | return proj.Apps, nil 31 | } 32 | 33 | var emptyApp = App{} 34 | 35 | func (p *AppsRepository) GetApp(id string) (App, error) { 36 | apps, err := p.GetApps() 37 | if err != nil { 38 | return emptyApp, err 39 | } 40 | 41 | for _, app := range apps { 42 | if app.ID == id { 43 | return app, nil 44 | } 45 | } 46 | 47 | return emptyApp, fmt.Errorf("Cannot find app") 48 | } 49 | 50 | func (p *AppsRepository) SaveApp(app App) (string, string, error) { 51 | apps, err := p.GetApps() 52 | if err != nil { 53 | return "", "", err 54 | } 55 | 56 | newName := util.NormalizeName(app.Name) 57 | for _, a := range apps { 58 | if util.NormalizeName(a.Name) == newName && a.ID != app.ID { 59 | return "", "", fmt.Errorf("App name must be unique") 60 | } 61 | } 62 | 63 | oldName := app.Name 64 | if app.ID == "" { 65 | app.ID = uuid.NewString() 66 | apps = append(apps, app) 67 | } else { 68 | for i, a := range apps { 69 | if a.ID == app.ID { 70 | oldName = a.Name 71 | apps[i] = app 72 | break 73 | } 74 | } 75 | } 76 | 77 | err = util.WriteFile(&Apps{Apps: apps}, "apps.json") 78 | return app.ID, oldName, err 79 | } 80 | 81 | func (p *AppsRepository) DeleteApp(id string) error { 82 | apps, err := p.GetApps() 83 | if err != nil { 84 | return err 85 | } 86 | 87 | for i, app := range apps { 88 | if app.ID == id { 89 | apps = append(apps[:i], apps[i+1:]...) 90 | break 91 | } 92 | } 93 | 94 | return util.WriteFile(&Apps{Apps: apps}, "apps.json") 95 | } 96 | -------------------------------------------------------------------------------- /pkg/data/auth.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "github.com/nerijusdu/vesa/pkg/util" 5 | ) 6 | 7 | type AuthRepository struct{} 8 | 9 | func NewAuthRepository() *AuthRepository { 10 | if !util.FileExists("auth.json") { 11 | err := util.WriteFile(&Registries{Registries: []RegistryAuth{}}, "auth.json") 12 | if err != nil { 13 | panic(err) 14 | } 15 | } 16 | 17 | return &AuthRepository{} 18 | } 19 | 20 | func (r *AuthRepository) GetAuths() ([]RegistryAuth, error) { 21 | auths, err := util.ReadFile[Registries]("auth.json") 22 | if err != nil { 23 | return nil, err 24 | } 25 | 26 | return auths.Registries, nil 27 | } 28 | 29 | func (r *AuthRepository) SaveAuth(auth RegistryAuth) error { 30 | auths, err := r.GetAuths() 31 | if err != nil { 32 | return err 33 | } 34 | 35 | found := false 36 | for i, a := range auths { 37 | if a.ServerAddress == a.ServerAddress { 38 | auths[i] = auth 39 | found = true 40 | break 41 | } 42 | } 43 | 44 | if !found { 45 | auths = append(auths, auth) 46 | } 47 | 48 | err = util.WriteFile(&Registries{Registries: auths}, "auth.json") 49 | return err 50 | } 51 | 52 | func (r *AuthRepository) GetToken(serverUrl string) (string, error) { 53 | auths, err := r.GetAuths() 54 | if err != nil { 55 | return "", err 56 | } 57 | 58 | for _, a := range auths { 59 | if a.ServerAddress == serverUrl { 60 | return a.IdentityToken, nil 61 | } 62 | } 63 | 64 | return "", nil 65 | } 66 | -------------------------------------------------------------------------------- /pkg/data/jobs.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | "github.com/nerijusdu/vesa/pkg/util" 6 | ) 7 | 8 | type JobsRepository struct { 9 | } 10 | 11 | func NewJobsRepository() *JobsRepository { 12 | if !util.FileExists("jobs.json") { 13 | err := util.WriteFile(&Jobs{Jobs: []Job{}}, "jobs.json") 14 | if err != nil { 15 | panic(err) 16 | } 17 | } 18 | 19 | return &JobsRepository{} 20 | } 21 | 22 | func (r *JobsRepository) GetJobs() ([]Job, error) { 23 | jobs, err := util.ReadFile[Jobs]("jobs.json") 24 | if err != nil { 25 | return nil, err 26 | } 27 | return jobs.Jobs, nil 28 | } 29 | 30 | var emptyJob = Job{} 31 | 32 | func (r *JobsRepository) GetJob(id string) (Job, error) { 33 | jobs, err := r.GetJobs() 34 | if err != nil { 35 | return emptyJob, err 36 | } 37 | 38 | for _, job := range jobs { 39 | if job.ID == id { 40 | return job, nil 41 | } 42 | } 43 | 44 | return emptyJob, nil 45 | } 46 | 47 | func (r *JobsRepository) SaveJob(job Job) (string, error) { 48 | jobs, err := r.GetJobs() 49 | if err != nil { 50 | return "", err 51 | } 52 | 53 | if job.ID == "" { 54 | job.ID = uuid.NewString() 55 | jobs = append(jobs, job) 56 | } else { 57 | for i, j := range jobs { 58 | if j.ID == job.ID { 59 | jobs[i] = job 60 | } 61 | } 62 | } 63 | 64 | err = util.WriteFile(&Jobs{Jobs: jobs}, "jobs.json") 65 | if err != nil { 66 | return "", err 67 | } 68 | 69 | return job.ID, nil 70 | } 71 | 72 | func (r *JobsRepository) DeleteJob(id string) error { 73 | jobs, err := r.GetJobs() 74 | if err != nil { 75 | return err 76 | } 77 | 78 | for i, job := range jobs { 79 | if job.ID == id { 80 | jobs = append(jobs[:i], jobs[i+1:]...) 81 | break 82 | } 83 | } 84 | 85 | err = util.WriteFile(&Jobs{Jobs: jobs}, "jobs.json") 86 | if err != nil { 87 | return err 88 | } 89 | 90 | return nil 91 | } 92 | -------------------------------------------------------------------------------- /pkg/data/logs.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | "time" 7 | 8 | "github.com/nerijusdu/vesa/pkg/util" 9 | ) 10 | 11 | type LogsRepository struct{} 12 | 13 | type Log struct { 14 | Message string `json:"message"` 15 | Tag string `json:"tag"` 16 | } 17 | 18 | func NewLogsRepository() *LogsRepository { 19 | if !util.FileExists("logs.txt") { 20 | content := "" 21 | err := util.WriteFile(&content, "logs.txt") 22 | if err != nil { 23 | panic(err) 24 | } 25 | } 26 | 27 | return &LogsRepository{} 28 | } 29 | 30 | func (r *LogsRepository) GetLogs(tag string) ([]string, error) { 31 | result := []string{} 32 | content, err := util.ReadTxtFile("logs.txt") 33 | if err != nil { 34 | return result, err 35 | } 36 | 37 | for _, s := range strings.Split(*content, "\n") { 38 | if s == "" { 39 | continue 40 | } 41 | vals := strings.Split(s, "|") 42 | if vals[0] == tag { 43 | rest := strings.Join(vals[1:], "|") 44 | result = append(result, rest) 45 | } 46 | } 47 | 48 | return result, nil 49 | } 50 | 51 | func (r *LogsRepository) Write(tag, message string) error { 52 | timestamp := time.Now().Format("2006-01-02 15:04:05") 53 | log := tag + "|" + timestamp + " " + message + "\n" 54 | fmt.Println("log", log) 55 | err := util.AppendToFile("logs.txt", log) 56 | return err 57 | } 58 | 59 | func (r *LogsRepository) DeleteLogs(tag string) error { 60 | content, err := util.ReadTxtFile("logs.txt") 61 | if err != nil { 62 | return err 63 | } 64 | 65 | lines := strings.Split(*content, "\n") 66 | newContent := "" 67 | for _, line := range lines { 68 | if strings.HasPrefix(line, tag) { 69 | continue 70 | } 71 | 72 | newContent += line + "\n" 73 | } 74 | 75 | return util.WriteFile(&newContent, "logs.txt") 76 | } 77 | -------------------------------------------------------------------------------- /pkg/data/models.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import "github.com/nerijusdu/vesa/pkg/dockerctrl" 4 | 5 | type Project struct { 6 | ID string `json:"id"` 7 | Name string `json:"name" validate:"required"` 8 | Containers []string `json:"containers"` 9 | NetworkId string `json:"networkId" validate:"required_without=NetworkName"` 10 | NetworkName string `json:"networkName" validate:"required_without=NetworkId"` 11 | } 12 | 13 | type Projects struct { 14 | Projects []Project `json:"projects"` 15 | } 16 | 17 | type Template struct { 18 | ID string `json:"id"` 19 | IsSystem bool `json:"isSystem"` 20 | Container dockerctrl.RunContainerRequest `json:"container"` 21 | } 22 | 23 | type Templates struct { 24 | Templates []Template `json:"templates"` 25 | } 26 | 27 | type Registries struct { 28 | Registries []RegistryAuth `json:"registries"` 29 | } 30 | 31 | type RegistryAuth struct { 32 | ServerAddress string `json:"serverAddress"` 33 | IdentityToken string `json:"identityToken"` 34 | } 35 | 36 | type App struct { 37 | ID string `json:"id"` 38 | Name string `json:"name" validate:"required"` 39 | Route string `json:"route" validate:"required"` 40 | Domain dockerctrl.DomainConfig `json:"domain" validate:"required"` 41 | } 42 | 43 | type Apps struct { 44 | Apps []App `json:"apps"` 45 | } 46 | 47 | type Job struct { 48 | ID string `json:"id"` 49 | Name string `json:"name" validate:"required"` 50 | Url string `json:"url" validate:"required"` 51 | Secret string `json:"secret"` 52 | Schedule string `json:"schedule" validate:"required"` 53 | Enabled bool `json:"enabled"` 54 | } 55 | 56 | type Jobs struct { 57 | Jobs []Job `json:"jobs"` 58 | } 59 | 60 | type TraefikRoutesConfig struct { 61 | Http TraefikHttpConfig `yaml:"http"` 62 | } 63 | 64 | type TraefikHttpConfig struct { 65 | Routers *map[string]TraefikRouter `yaml:"routers"` 66 | Middlewares map[string]TraefikMiddleware `yaml:"middlewares"` 67 | Services *map[string]TraefikService `yaml:"services"` 68 | } 69 | 70 | type TraefikRouter struct { 71 | EntryPoints []string `yaml:"entryPoints"` 72 | Middlewares []string `yaml:"middlewares"` 73 | Service string `yaml:"service"` 74 | Rule string `yaml:"rule"` 75 | Tls *TraefikTlsConfig `yaml:"tls,omitempty"` 76 | } 77 | 78 | type TraefikTlsConfig struct { 79 | CertResolver string `yaml:"certResolver"` 80 | } 81 | 82 | type TraefikMiddleware struct { 83 | RedirectScheme *RedirectSchemeMiddleware `yaml:"redirectScheme,omitempty"` 84 | ReplacePath *ReplacePathMiddleware `yaml:"replacePath,omitempty"` 85 | StripPrefix *StripPrefixMiddleware `yaml:"stripPrefix,omitempty"` 86 | Headers *HeadersMiddleware `yaml:"headers,omitempty"` 87 | } 88 | 89 | type RedirectSchemeMiddleware struct { 90 | Scheme string `yaml:"scheme"` 91 | Permanent bool `yaml:"permanent"` 92 | } 93 | 94 | type ReplacePathMiddleware struct { 95 | Path string `yaml:"path"` 96 | } 97 | 98 | type StripPrefixMiddleware struct { 99 | Prefixes []string `yaml:"prefixes"` 100 | } 101 | 102 | type HeadersMiddleware struct { 103 | CustomRequestHeaders map[string]string `yaml:"customRequestHeaders"` 104 | } 105 | 106 | type TraefikService struct { 107 | LoadBalancer TraefikLoadBalancer `yaml:"loadBalancer"` 108 | } 109 | 110 | type TraefikLoadBalancer struct { 111 | Servers []TraefikServer `yaml:"servers"` 112 | PassHostHeader bool `yaml:"passHostHeader"` 113 | } 114 | 115 | type TraefikServer struct { 116 | URL string `yaml:"url"` 117 | } 118 | -------------------------------------------------------------------------------- /pkg/data/projects.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "github.com/google/uuid" 5 | "github.com/nerijusdu/vesa/pkg/util" 6 | ) 7 | 8 | type ProjectsRepository struct { 9 | } 10 | 11 | func NewProjectsRepository() *ProjectsRepository { 12 | if !util.FileExists("projects.json") { 13 | err := util.WriteFile(&Projects{Projects: []Project{}}, "projects.json") 14 | if err != nil { 15 | panic(err) 16 | } 17 | } 18 | 19 | return &ProjectsRepository{} 20 | } 21 | 22 | func (p *ProjectsRepository) GetProjects() ([]Project, error) { 23 | proj, err := util.ReadFile[Projects]("projects.json") 24 | if err != nil { 25 | return nil, err 26 | } 27 | 28 | return proj.Projects, nil 29 | } 30 | 31 | var emptyProj = Project{} 32 | 33 | func (p *ProjectsRepository) GetProject(id string) (Project, error) { 34 | projects, err := p.GetProjects() 35 | if err != nil { 36 | return emptyProj, err 37 | } 38 | 39 | for _, project := range projects { 40 | if project.ID == id { 41 | return project, nil 42 | } 43 | } 44 | 45 | return emptyProj, nil 46 | } 47 | 48 | func (p *ProjectsRepository) SaveProject(project Project) (string, error) { 49 | projects, err := p.GetProjects() 50 | if err != nil { 51 | return "", err 52 | } 53 | 54 | if project.ID == "" { 55 | project.ID = uuid.NewString() 56 | projects = append(projects, project) 57 | } else { 58 | for i, proj := range projects { 59 | if proj.ID == project.ID { 60 | projects[i] = project 61 | break 62 | } 63 | } 64 | } 65 | 66 | err = util.WriteFile(&Projects{Projects: projects}, "projects.json") 67 | return project.ID, err 68 | } 69 | 70 | func (p *ProjectsRepository) DeleteProject(id string) error { 71 | projects, err := p.GetProjects() 72 | if err != nil { 73 | return err 74 | } 75 | 76 | for i, project := range projects { 77 | if project.ID == id { 78 | projects = append(projects[:i], projects[i+1:]...) 79 | break 80 | } 81 | } 82 | 83 | return util.WriteFile(&Projects{Projects: projects}, "projects.json") 84 | } 85 | -------------------------------------------------------------------------------- /pkg/data/templates.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import ( 4 | "embed" 5 | "encoding/json" 6 | "io" 7 | "os" 8 | "strings" 9 | "text/template" 10 | 11 | "github.com/google/uuid" 12 | "github.com/nerijusdu/vesa/pkg/config" 13 | "github.com/nerijusdu/vesa/pkg/dockerctrl" 14 | "github.com/nerijusdu/vesa/pkg/util" 15 | ) 16 | 17 | type TemplateRepository struct { 18 | defaultTemplates []Template 19 | } 20 | 21 | type SystemTemplateVars struct { 22 | UserEmail string 23 | ConfigDir string 24 | EnableDashboard string 25 | } 26 | 27 | func exists(path string) bool { 28 | _, err := os.Stat(path) 29 | if err == nil { 30 | return true 31 | } 32 | if os.IsNotExist(err) { 33 | return false 34 | } 35 | return false 36 | } 37 | 38 | func moveTeamplateToConfigDir(dir embed.FS) { 39 | dataDir, err := util.GetDataDir() 40 | if err != nil { 41 | panic(err) 42 | } 43 | 44 | if exists(dataDir + "/templates") { 45 | return 46 | } 47 | 48 | os.MkdirAll(dataDir+"/templates", 0755) 49 | 50 | entries, err := dir.ReadDir("templates") 51 | if err != nil { 52 | panic(err) 53 | } 54 | 55 | for _, entry := range entries { 56 | p := "templates/" + entry.Name() 57 | content, err := dir.ReadFile(p) 58 | if err != nil { 59 | panic(err) 60 | } 61 | str := string(content) 62 | err = util.WriteFile(&str, p) 63 | if err != nil { 64 | panic(err) 65 | } 66 | } 67 | } 68 | 69 | func NewTemplateRepository(defaultTemplatesDir embed.FS, c *config.Config) *TemplateRepository { 70 | if !util.FileExists("templates.json") { 71 | err := util.WriteFile(&Templates{Templates: []Template{}}, "templates.json") 72 | if err != nil { 73 | panic(err) 74 | } 75 | } 76 | 77 | dataDir, err := util.GetDataDir() 78 | if err != nil { 79 | panic(err) 80 | } 81 | 82 | templateVars := SystemTemplateVars{ 83 | UserEmail: c.UserEmail, 84 | ConfigDir: dataDir, 85 | EnableDashboard: "false", 86 | } 87 | if c.EnableDashboard { 88 | templateVars.EnableDashboard = "true" 89 | } 90 | 91 | moveTeamplateToConfigDir(defaultTemplatesDir) 92 | 93 | tmpl, err := template.ParseFS(os.DirFS(dataDir), "templates/*") 94 | if err != nil { 95 | panic(err) 96 | } 97 | 98 | var defaultTemplates []Template 99 | for _, t := range tmpl.Templates() { 100 | reader, writer := io.Pipe() 101 | go func() { 102 | err := t.Execute(writer, templateVars) 103 | writer.CloseWithError(err) 104 | }() 105 | 106 | template := &dockerctrl.RunContainerRequest{} 107 | if err := json.NewDecoder(reader).Decode(template); err != nil { 108 | panic(err) 109 | } 110 | 111 | defaultTemplates = append(defaultTemplates, Template{ 112 | ID: "system-template:" + strings.Split(t.Name(), ".")[0], 113 | IsSystem: true, 114 | Container: *template, 115 | }) 116 | } 117 | 118 | return &TemplateRepository{ 119 | defaultTemplates: defaultTemplates, 120 | } 121 | } 122 | 123 | func (t *TemplateRepository) GetTemplates() ([]Template, error) { 124 | templates, err := util.ReadFile[Templates]("templates.json") 125 | if err != nil { 126 | return nil, err 127 | } 128 | 129 | allTemplates := append(templates.Templates, t.defaultTemplates...) 130 | 131 | return allTemplates, nil 132 | } 133 | 134 | var emptyTemplate = Template{} 135 | 136 | func (t *TemplateRepository) GetTemplate(id string) (Template, error) { 137 | templates, err := t.GetTemplates() 138 | if err != nil { 139 | return emptyTemplate, err 140 | } 141 | 142 | for _, template := range templates { 143 | if template.ID == id { 144 | return template, nil 145 | } 146 | } 147 | 148 | return emptyTemplate, nil 149 | } 150 | 151 | func (t *TemplateRepository) SaveTemplate(template Template) (string, error) { 152 | templates, err := t.GetTemplates() 153 | if err != nil { 154 | return "", err 155 | } 156 | 157 | if template.ID == "" { 158 | template.ID = uuid.NewString() 159 | templates = append(templates, template) 160 | } else { 161 | for i, temp := range templates { 162 | if temp.ID == template.ID { 163 | templates[i] = template 164 | } 165 | } 166 | } 167 | 168 | templatesToSave := []Template{} 169 | for _, temp := range templates { 170 | if !strings.Contains(temp.ID, "system-template") { 171 | templatesToSave = append(templatesToSave, temp) 172 | } 173 | } 174 | 175 | err = util.WriteFile(&Templates{Templates: templatesToSave}, "templates.json") 176 | if err != nil { 177 | return "", err 178 | } 179 | 180 | return template.ID, nil 181 | } 182 | 183 | func (t *TemplateRepository) DeleteTemplate(id string) error { 184 | templates, err := t.GetTemplates() 185 | if err != nil { 186 | return err 187 | } 188 | 189 | for i, template := range templates { 190 | if template.ID == id { 191 | templates = append(templates[:i], templates[i+1:]...) 192 | } 193 | } 194 | 195 | templatesToSave := []Template{} 196 | for _, temp := range templates { 197 | if !strings.Contains(temp.ID, "system-template") { 198 | templatesToSave = append(templatesToSave, temp) 199 | } 200 | } 201 | 202 | err = util.WriteFile(&Templates{Templates: templatesToSave}, "templates.json") 203 | if err != nil { 204 | return err 205 | } 206 | 207 | return nil 208 | } 209 | -------------------------------------------------------------------------------- /pkg/data/traefik.go: -------------------------------------------------------------------------------- 1 | package data 2 | 3 | import "github.com/nerijusdu/vesa/pkg/util" 4 | 5 | type TraefikRepository struct { 6 | } 7 | 8 | var middlewares = map[string]TraefikMiddleware{ 9 | "redirect-to-https": { 10 | RedirectScheme: &RedirectSchemeMiddleware{ 11 | Scheme: "https", 12 | Permanent: true, 13 | }, 14 | }, 15 | } 16 | 17 | func NewTraefikRepository() *TraefikRepository { 18 | repo := &TraefikRepository{} 19 | 20 | if !util.FileExists("routes.yaml") { 21 | err := util.WriteFile(&TraefikRoutesConfig{ 22 | Http: TraefikHttpConfig{ 23 | Middlewares: middlewares, 24 | }, 25 | }, "routes.yaml") 26 | if err != nil { 27 | panic(err) 28 | } 29 | } else { 30 | routes, err := repo.GetRoutes() 31 | if err != nil { 32 | panic(err) 33 | } 34 | 35 | for k := range middlewares { 36 | if _, ok := routes.Http.Middlewares[k]; !ok { 37 | routes.Http.Middlewares = middlewares 38 | break 39 | } 40 | } 41 | 42 | repo.SaveRoutes(routes) 43 | } 44 | 45 | if !util.FileExists("acme_json") { 46 | util.CreateEmptyFile("acme_json") 47 | } 48 | 49 | return repo 50 | } 51 | 52 | var emptyTraefikRoutesConfig = TraefikRoutesConfig{} 53 | 54 | func (r *TraefikRepository) GetRoutes() (TraefikRoutesConfig, error) { 55 | c, err := util.ReadFile[TraefikRoutesConfig]("routes.yaml") 56 | if err != nil { 57 | return emptyTraefikRoutesConfig, err 58 | } 59 | 60 | return *c, nil 61 | } 62 | 63 | func (r *TraefikRepository) SaveRoutes(c TraefikRoutesConfig) error { 64 | return util.WriteFile(&c, "routes.yaml") 65 | } 66 | -------------------------------------------------------------------------------- /pkg/dockerctrl/client.go: -------------------------------------------------------------------------------- 1 | package dockerctrl 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/docker/docker/api/types" 7 | "github.com/docker/docker/client" 8 | ) 9 | 10 | type authRepository interface { 11 | GetToken(serverUrl string) (string, error) 12 | } 13 | 14 | type DockerCtrlClient struct { 15 | Client *client.Client 16 | auth authRepository 17 | } 18 | 19 | func NewDockerCtrlClient(auth authRepository) (*DockerCtrlClient, error) { 20 | cli, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation()) 21 | if err != nil { 22 | return nil, err 23 | } 24 | 25 | return &DockerCtrlClient{ 26 | Client: cli, 27 | auth: auth, 28 | }, nil 29 | } 30 | 31 | func (d *DockerCtrlClient) Close() { 32 | d.Client.Close() 33 | } 34 | 35 | func (d *DockerCtrlClient) Authenticate(req AuthRequest) (string, error) { 36 | ctx := context.Background() 37 | 38 | res, err := d.Client.RegistryLogin(ctx, types.AuthConfig{ 39 | Username: req.Username, 40 | Password: req.Password, 41 | ServerAddress: req.ServerAddress, 42 | }) 43 | 44 | if err != nil { 45 | return "", err 46 | } 47 | 48 | return res.IdentityToken, nil 49 | } 50 | -------------------------------------------------------------------------------- /pkg/dockerctrl/container.go: -------------------------------------------------------------------------------- 1 | package dockerctrl 2 | 3 | import ( 4 | "context" 5 | "encoding/csv" 6 | "io" 7 | "os" 8 | "strings" 9 | "time" 10 | 11 | "github.com/docker/docker/api/types" 12 | "github.com/docker/docker/api/types/container" 13 | "github.com/docker/docker/api/types/filters" 14 | "github.com/docker/docker/api/types/network" 15 | "github.com/nerijusdu/vesa/pkg/util" 16 | ) 17 | 18 | func (d *DockerCtrlClient) GetContainers(req GetContainersRequest) ([]Container, error) { 19 | ctx := context.Background() 20 | 21 | var f filters.Args 22 | if req.Label != "" { 23 | f = filters.NewArgs(filters.KeyValuePair{ 24 | Key: "label", 25 | Value: req.Label, 26 | }) 27 | } 28 | 29 | containers, err := d.Client.ContainerList(ctx, types.ContainerListOptions{ 30 | All: true, 31 | Filters: f, 32 | }) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | res := util.Map(containers, mapContainer) 38 | util.Sort(res, func(a, b Container) bool { 39 | if a.State == b.State { 40 | return a.Names[0] > b.Names[0] 41 | } 42 | return a.State != "running" && b.State == "running" 43 | }) 44 | return res, nil 45 | } 46 | 47 | func (d *DockerCtrlClient) RunContainer(req RunContainerRequest) (string, error) { 48 | ctx := context.Background() 49 | 50 | if !req.IsLocal { 51 | err := d.PullImage(req.Image) 52 | if err != nil { 53 | return "", err 54 | } 55 | } 56 | 57 | ports, err := getPortMap(req.Ports) 58 | if err != nil { 59 | return "", err 60 | } 61 | 62 | portSet := getPortSet(ports) 63 | 64 | mounts := util.Map(req.Mounts, mapMount) 65 | networkEndpoints := map[string]*network.EndpointSettings{} 66 | cmd := []string{} 67 | 68 | if req.Command != "" { 69 | r := csv.NewReader(strings.NewReader(req.Command)) 70 | r.Comma = ' ' 71 | cmd, err = r.Read() 72 | if err != nil { 73 | return "", err 74 | } 75 | } 76 | 77 | addDomainLabels(req) 78 | ensureMountPaths(mounts) 79 | 80 | if len(req.Networks) > 0 { 81 | // can only create container with one network 82 | // attach them later 83 | networkEndpoints[req.Networks[0].NetworkName] = &network.EndpointSettings{ 84 | NetworkID: req.Networks[0].NetworkId, 85 | } 86 | } 87 | 88 | resp, err := d.Client.ContainerCreate(ctx, &container.Config{ 89 | Image: req.Image, 90 | Env: req.EnvVars, 91 | ExposedPorts: portSet, 92 | Labels: req.Labels, 93 | Cmd: cmd, 94 | }, &container.HostConfig{ 95 | RestartPolicy: mapRestartPolicy(req.RestartPolicy), 96 | PortBindings: ports, 97 | Mounts: mounts, 98 | ExtraHosts: []string{"host.docker.internal:host-gateway"}, 99 | }, &network.NetworkingConfig{ 100 | EndpointsConfig: networkEndpoints, 101 | }, nil, req.Name) 102 | if err != nil { 103 | return "", err 104 | } 105 | 106 | if len(req.Networks) > 1 { 107 | for _, n := range req.Networks[1:] { 108 | if err := d.Client.NetworkConnect(ctx, n.NetworkId, resp.ID, nil); err != nil { 109 | return "", err 110 | } 111 | } 112 | } 113 | 114 | if err := d.Client.ContainerStart(ctx, resp.ID, types.ContainerStartOptions{}); err != nil { 115 | return "", err 116 | } 117 | 118 | return resp.ID, nil 119 | } 120 | 121 | func (d *DockerCtrlClient) GetContainer(id string) (ContainerDetails, error) { 122 | ctx := context.Background() 123 | container, err := d.Client.ContainerInspect(ctx, id) 124 | if err != nil { 125 | return ContainerDetails{}, err 126 | } 127 | return mapContainerDetails(container), nil 128 | } 129 | 130 | func (d *DockerCtrlClient) DeleteContainer(id string) error { 131 | ctx := context.Background() 132 | return d.Client.ContainerRemove(ctx, id, types.ContainerRemoveOptions{}) 133 | } 134 | 135 | func (d *DockerCtrlClient) StopContainer(id string) error { 136 | ctx := context.Background() 137 | return d.Client.ContainerStop(ctx, id, nil) 138 | } 139 | 140 | func (d *DockerCtrlClient) StartContainer(id string) error { 141 | ctx := context.Background() 142 | return d.Client.ContainerStart(ctx, id, types.ContainerStartOptions{}) 143 | } 144 | 145 | func (d *DockerCtrlClient) RestartContainer(id string) error { 146 | ctx := context.Background() 147 | timeout, err := time.ParseDuration("60s") 148 | if err != nil { 149 | return err 150 | } 151 | 152 | return d.Client.ContainerRestart(ctx, id, &timeout) 153 | } 154 | 155 | func (d *DockerCtrlClient) PullImage(image string) error { 156 | ctx := context.Background() 157 | 158 | token := "" 159 | splits := strings.Split(image, "/") 160 | if len(splits) > 0 { 161 | t, err := d.auth.GetToken(splits[0]) 162 | if err != nil { 163 | return err 164 | } 165 | token = t 166 | } 167 | 168 | out, err := d.Client.ImagePull(ctx, image, types.ImagePullOptions{ 169 | RegistryAuth: token, 170 | }) 171 | if err != nil { 172 | return err 173 | } 174 | defer out.Close() 175 | 176 | io.Copy(os.Stdout, out) 177 | return nil 178 | } 179 | 180 | func (d *DockerCtrlClient) GetContainerLogs(id string) (io.ReadCloser, error) { 181 | ctx := context.Background() 182 | return d.Client.ContainerLogs(ctx, id, types.ContainerLogsOptions{ 183 | ShowStdout: true, 184 | ShowStderr: true, 185 | Follow: false, 186 | Timestamps: false, 187 | Tail: "100", 188 | }) 189 | } 190 | -------------------------------------------------------------------------------- /pkg/dockerctrl/helpers.go: -------------------------------------------------------------------------------- 1 | package dockerctrl 2 | 3 | import ( 4 | "os" 5 | "strings" 6 | 7 | "github.com/docker/docker/api/types/mount" 8 | "github.com/nerijusdu/vesa/pkg/util" 9 | ) 10 | 11 | func ensureMountPaths(mounts []mount.Mount) { 12 | for _, m := range mounts { 13 | if m.Type == mount.TypeBind { 14 | p := m.Source 15 | if _, err := os.Stat(p); os.IsNotExist(err) { 16 | os.MkdirAll(p, 0755) 17 | } 18 | } 19 | } 20 | } 21 | 22 | func addDomainLabels(req RunContainerRequest) map[string]string { 23 | if req.Domain.Host != "" { 24 | req.Labels["traefik.enable"] = "true" 25 | rule := util.BuildTraefikRule(req.Domain.Host, req.Domain.PathPrefixes) 26 | req.Labels["traefik.http.routers."+req.Name+".rule"] = rule 27 | 28 | if len(req.Domain.PathPrefixes) > 0 && req.Domain.StripPrefix { 29 | req.Labels["traefik.http.routers."+req.Name+".middlewares"] = "strip-prefix-" + req.Name 30 | req.Labels["traefik.http.middlewares.strip-prefix-"+req.Name+".stripprefix.prefixes"] = strings.Join(req.Domain.PathPrefixes, ",") 31 | } 32 | 33 | if len(req.Domain.Headers) > 0 { 34 | req.Labels["traefik.http.routers."+req.Name+".middlewares"] = "add-headers-" + req.Name 35 | for _, h := range req.Domain.Headers { 36 | req.Labels["traefik.http.middlewares.add-headers-"+req.Name+".headers.customrequestheaders"+h.Name] = h.Value 37 | } 38 | } 39 | 40 | for _, e := range req.Domain.Entrypoints { 41 | req.Labels["traefik.http.routers."+req.Name+".entrypoints"] = e 42 | 43 | if e == "websecure" { 44 | req.Labels["traefik.http.routers."+req.Name+".tls.certResolver"] = "vesaresolver" 45 | req.Labels["traefik.http.routers."+req.Name+"-http.rule"] = rule 46 | req.Labels["traefik.http.routers."+req.Name+"-http.middlewares"] = "redirect-to-https@file" 47 | req.Labels["traefik.http.routers."+req.Name+"-http.entrypoints"] = "web" 48 | } 49 | } 50 | } 51 | 52 | return req.Labels 53 | } 54 | -------------------------------------------------------------------------------- /pkg/dockerctrl/mappers.go: -------------------------------------------------------------------------------- 1 | package dockerctrl 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/docker/docker/api/types" 7 | "github.com/docker/docker/api/types/container" 8 | "github.com/docker/docker/api/types/mount" 9 | "github.com/docker/docker/api/types/network" 10 | "github.com/docker/go-connections/nat" 11 | "github.com/nerijusdu/vesa/pkg/util" 12 | ) 13 | 14 | func mapContainer(c types.Container) Container { 15 | return Container{ 16 | ID: c.ID, 17 | Names: c.Names, 18 | Image: c.Image, 19 | Command: c.Command, 20 | Created: c.Created, 21 | Ports: util.Map(c.Ports, mapPort), 22 | Labels: c.Labels, 23 | State: c.State, 24 | Status: c.Status, 25 | } 26 | } 27 | 28 | func mapPort(p types.Port) Port { 29 | return Port{ 30 | IP: p.IP, 31 | PrivatePort: p.PrivatePort, 32 | PublicPort: p.PublicPort, 33 | Type: p.Type, 34 | } 35 | } 36 | 37 | func mapContainerDetails(c types.ContainerJSON) ContainerDetails { 38 | return ContainerDetails{ 39 | ID: c.ID, 40 | Created: c.Created, 41 | Path: c.Path, 42 | Args: c.Args, 43 | State: c.State.Status, 44 | Image: c.Image, 45 | Name: c.Name, 46 | Driver: c.Driver, 47 | Platform: c.Platform, 48 | Mounts: util.Map(c.Mounts, mapMountPoint), 49 | HostConfig: &HostConfig{ 50 | NetworkMode: string(c.HostConfig.NetworkMode), 51 | PortBindings: util.MapDict(c.HostConfig.PortBindings, mapPortBinding), 52 | RestartPolicy: RestartPolicy{ 53 | Name: c.HostConfig.RestartPolicy.Name, 54 | MaximumRetryCount: c.HostConfig.RestartPolicy.MaximumRetryCount, 55 | }, 56 | AutoRemove: c.HostConfig.AutoRemove, 57 | }, 58 | Config: &ContainerConfig{ 59 | Env: c.Config.Env, 60 | Image: c.Config.Image, 61 | Cmd: c.Config.Cmd, 62 | Labels: c.Config.Labels, 63 | }, 64 | NetworkSettings: &NetworkSettings{ 65 | Networks: util.MapDict(c.NetworkSettings.Networks, mapNetworkSettingsNetwork), 66 | }, 67 | } 68 | } 69 | 70 | func mapPortBinding(p []nat.PortBinding) []PortBinding { 71 | return util.Map(p, func(b nat.PortBinding) PortBinding { 72 | return PortBinding{ 73 | HostIP: b.HostIP, 74 | HostPort: b.HostPort, 75 | } 76 | }) 77 | } 78 | 79 | func mapNetworkSettingsNetwork(n *network.EndpointSettings) NetworkSettingsNetwork { 80 | return NetworkSettingsNetwork{ 81 | NetworkID: n.NetworkID, 82 | } 83 | } 84 | 85 | func getPortMap(ports []string) (nat.PortMap, error) { 86 | portBindings := nat.PortMap{} 87 | for _, portStr := range ports { 88 | p, err := nat.ParsePortSpec(portStr) 89 | if err != nil { 90 | return nil, err 91 | } 92 | 93 | for _, port := range p { 94 | portBindings[port.Port] = []nat.PortBinding{port.Binding} 95 | } 96 | } 97 | 98 | return portBindings, nil 99 | } 100 | 101 | func getPortSet(ports nat.PortMap) nat.PortSet { 102 | portSet := nat.PortSet{} 103 | 104 | for k := range ports { 105 | portSet[k] = struct{}{} 106 | } 107 | 108 | return portSet 109 | } 110 | 111 | func mapNetwork(n types.NetworkResource) Network { 112 | return Network{ 113 | ID: n.ID, 114 | Name: n.Name, 115 | Scope: n.Scope, 116 | Created: n.Created, 117 | Driver: n.Driver, 118 | Internal: n.Internal, 119 | Attachable: n.Attachable, 120 | Containers: util.MapDict(n.Containers, mapNetworkContainer), 121 | } 122 | } 123 | 124 | func mapNetworkContainer(c types.EndpointResource) NetworkContainer { 125 | return NetworkContainer{ 126 | Name: c.Name, 127 | EndpointID: c.EndpointID, 128 | MacAddress: c.MacAddress, 129 | IPv4Address: c.IPv4Address, 130 | IPv6Address: c.IPv6Address, 131 | } 132 | } 133 | 134 | func mapMount(m Mount) mount.Mount { 135 | return mount.Mount{ 136 | Type: mount.Type(m.Type), 137 | Source: m.Source, 138 | Target: m.Target, 139 | } 140 | } 141 | 142 | func mapMountPoint(m types.MountPoint) Mount { 143 | return Mount{ 144 | Type: string(m.Type), 145 | Source: m.Source, 146 | Target: m.Destination, 147 | Name: m.Name, 148 | } 149 | } 150 | 151 | func mapRestartPolicy(m RestartPolicy) container.RestartPolicy { 152 | return container.RestartPolicy{ 153 | Name: m.Name, 154 | MaximumRetryCount: m.MaximumRetryCount, 155 | } 156 | } 157 | 158 | func MapContainerToRequest(m ContainerDetails) RunContainerRequest { 159 | name := strings.Replace(m.Name, "/", "", 1) 160 | 161 | networks := make([]NetworkConfig, 0, len(m.NetworkSettings.Networks)) 162 | for name, v := range m.NetworkSettings.Networks { 163 | networks = append(networks, NetworkConfig{ 164 | NetworkId: v.NetworkID, 165 | NetworkName: name, 166 | }) 167 | } 168 | 169 | cmd := "" 170 | if len(m.Config.Cmd) > 0 { 171 | for _, c := range m.Config.Cmd { 172 | if len(cmd) > 0 { 173 | cmd += " " 174 | } 175 | if strings.Contains(c, " ") { 176 | cmd += "\"" + c + "\"" 177 | } else { 178 | cmd += c 179 | } 180 | } 181 | } 182 | 183 | return RunContainerRequest{ 184 | Image: m.Config.Image, 185 | Name: name, 186 | Ports: getPortStrings(m.HostConfig.PortBindings), 187 | Mounts: m.Mounts, 188 | EnvVars: m.Config.Env, 189 | RestartPolicy: m.HostConfig.RestartPolicy, 190 | Networks: networks, 191 | Command: cmd, 192 | } 193 | } 194 | 195 | func getPortStrings(m map[nat.Port][]PortBinding) []string { 196 | var result []string 197 | 198 | for k, v := range m { 199 | cPort := strings.Split(string(k), "/")[0] 200 | hPort := v[0].HostPort 201 | if v[0].HostIP != "" { 202 | hPort = v[0].HostIP + ":" + v[0].HostPort 203 | } 204 | result = append(result, hPort+":"+cPort) 205 | } 206 | 207 | return result 208 | } 209 | -------------------------------------------------------------------------------- /pkg/dockerctrl/models.go: -------------------------------------------------------------------------------- 1 | package dockerctrl 2 | 3 | import ( 4 | "time" 5 | 6 | "github.com/docker/go-connections/nat" 7 | ) 8 | 9 | type Container struct { 10 | ID string `json:"id"` 11 | Names []string `json:"names"` 12 | Image string `json:"image"` 13 | Command string `json:"command"` 14 | Created int64 `json:"created"` 15 | Ports []Port `json:"ports"` 16 | Labels map[string]string `json:"labels"` 17 | State string `json:"state"` 18 | Status string `json:"status"` 19 | } 20 | 21 | type Port struct { 22 | IP string `json:"ip,omitempty"` 23 | PrivatePort uint16 `json:"privatePort"` 24 | PublicPort uint16 `json:"publicPort,omitempty"` 25 | Type string `json:"type"` 26 | } 27 | 28 | type PortBinding struct { 29 | HostIP string `json:"hostIp"` 30 | HostPort string `json:"hostPort"` 31 | } 32 | 33 | type ContainerDetails struct { 34 | ID string `json:"id"` 35 | Created string `json:"created"` 36 | Path string `json:"path"` 37 | Args []string `json:"args"` 38 | State string `json:"state"` 39 | Image string `json:"image"` 40 | Name string `json:"name"` 41 | Driver string `json:"driver"` 42 | Platform string `json:"platform"` 43 | HostConfig *HostConfig `json:"hostConfig"` 44 | NetworkSettings *NetworkSettings `json:"networkSettings"` 45 | Config *ContainerConfig `json:"config"` 46 | Mounts []Mount `json:"mounts"` 47 | } 48 | 49 | type HostConfig struct { 50 | NetworkMode string `json:"networkMode"` 51 | PortBindings map[nat.Port][]PortBinding `json:"portBindings"` 52 | RestartPolicy RestartPolicy `json:"restartPolicy"` 53 | AutoRemove bool `json:"autoRemove"` 54 | } 55 | 56 | type RestartPolicy struct { 57 | Name string `json:"name"` 58 | MaximumRetryCount int `json:"maximumRetryCount"` 59 | } 60 | 61 | type NetworkSettings struct { 62 | Networks map[string]NetworkSettingsNetwork `json:"networks"` 63 | } 64 | 65 | type NetworkSettingsNetwork struct { 66 | NetworkID string `json:"networkId"` 67 | } 68 | 69 | type ContainerConfig struct { 70 | Env []string `json:"env"` 71 | Image string `json:"image"` 72 | Cmd []string `json:"cmd"` 73 | Labels map[string]string `json:"labels"` 74 | } 75 | 76 | type RunContainerRequest struct { 77 | Image string `json:"image" validate:"required"` 78 | Name string `json:"name"` 79 | Command string `json:"command"` 80 | Ports []string `json:"ports"` 81 | Mounts []Mount `json:"mounts" validate:"dive"` 82 | EnvVars []string `json:"envVars"` 83 | IsLocal bool `json:"isLocal"` 84 | Networks []NetworkConfig `json:"networks"` 85 | SaveAsTemplate bool `json:"saveAsTemplate"` 86 | RestartPolicy RestartPolicy `json:"restartPolicy"` 87 | Labels map[string]string `json:"labels"` 88 | Domain DomainConfig `json:"domain"` 89 | } 90 | 91 | type NetworkConfig struct { 92 | NetworkId string `json:"networkId" validate:"required"` 93 | NetworkName string `json:"networkName" validate:"required"` 94 | } 95 | 96 | type GetContainersRequest struct { 97 | Label string `json:"label"` 98 | } 99 | 100 | type Mount struct { 101 | Type string `json:"type" validate:"required"` 102 | Source string `json:"source" validate:"required"` 103 | Target string `json:"target" validate:"required"` 104 | Name string `json:"name"` 105 | } 106 | 107 | type Network struct { 108 | Name string `json:"name"` 109 | ID string `json:"id"` 110 | Created time.Time `json:"created"` 111 | Scope string `json:"scope"` 112 | Driver string `json:"driver"` 113 | Internal bool `json:"internal"` 114 | Attachable bool `json:"attachable"` 115 | Containers map[string]NetworkContainer `json:"containers"` 116 | } 117 | 118 | type DomainConfig struct { 119 | Host string `json:"host"` 120 | PathPrefixes []string `json:"pathPrefixes"` 121 | StripPrefix bool `json:"stripPrefix"` 122 | Entrypoints []string `json:"entrypoints"` 123 | Headers []Header `json:"headers"` 124 | } 125 | 126 | type Header struct { 127 | Name string `json:"name" validate:"required"` 128 | Value string `json:"value" validate:"required"` 129 | } 130 | 131 | type NetworkContainer struct { 132 | Name string `json:"name"` 133 | EndpointID string `json:"endpointId"` 134 | MacAddress string `json:"macAddress"` 135 | IPv4Address string `json:"ipv4Address"` 136 | IPv6Address string `json:"ipv6Address"` 137 | } 138 | 139 | type CreateNetworkRequest struct { 140 | Name string `json:"name" validate:"required"` 141 | Driver string `json:"driver"` 142 | Internal bool `json:"internal"` 143 | Attachable bool `json:"attachable"` 144 | } 145 | 146 | type AuthRequest struct { 147 | Username string `json:"username"` 148 | Password string `json:"password"` 149 | ServerAddress string `json:"serverAddress" validate:"required"` 150 | } 151 | 152 | type CreateClientRequest struct { 153 | ClientID string `json:"clientId" validate:"required"` 154 | ClientSecret string `json:"clientSecret" validate:"required"` 155 | } 156 | -------------------------------------------------------------------------------- /pkg/dockerctrl/network.go: -------------------------------------------------------------------------------- 1 | package dockerctrl 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/docker/docker/api/types" 7 | "github.com/nerijusdu/vesa/pkg/util" 8 | ) 9 | 10 | func (d *DockerCtrlClient) GetNetworks() ([]Network, error) { 11 | ctx := context.Background() 12 | networks, err := d.Client.NetworkList(ctx, types.NetworkListOptions{}) 13 | if err != nil { 14 | return nil, err 15 | } 16 | 17 | res := util.Map(networks, mapNetwork) 18 | util.Sort(res, func(a, b Network) bool { 19 | return a.Name > b.Name 20 | }) 21 | return res, nil 22 | } 23 | 24 | func (d *DockerCtrlClient) GetNetwork(id string) (Network, error) { 25 | ctx := context.Background() 26 | network, err := d.Client.NetworkInspect(ctx, id, types.NetworkInspectOptions{}) 27 | if err != nil { 28 | return Network{}, err 29 | } 30 | 31 | return mapNetwork(network), nil 32 | } 33 | 34 | func (d *DockerCtrlClient) CreateNetwork(req CreateNetworkRequest) (string, error) { 35 | ctx := context.Background() 36 | network, err := d.Client.NetworkCreate(ctx, req.Name, types.NetworkCreate{ 37 | CheckDuplicate: true, 38 | Driver: req.Driver, 39 | Internal: req.Internal, 40 | Attachable: req.Attachable, 41 | }) 42 | if err != nil { 43 | return "", err 44 | } 45 | 46 | return network.ID, nil 47 | } 48 | 49 | func (d *DockerCtrlClient) RemoveNetwork(id string) error { 50 | ctx := context.Background() 51 | return d.Client.NetworkRemove(ctx, id) 52 | } 53 | 54 | func (d *DockerCtrlClient) ConnectNetwork(networkID, containerID string) error { 55 | ctx := context.Background() 56 | return d.Client.NetworkConnect(ctx, networkID, containerID, nil) 57 | } 58 | 59 | func (d *DockerCtrlClient) DisconnectNetwork(networkID, containerID string) error { 60 | ctx := context.Background() 61 | return d.Client.NetworkDisconnect(ctx, networkID, containerID, true) 62 | } 63 | -------------------------------------------------------------------------------- /pkg/runner/runner.go: -------------------------------------------------------------------------------- 1 | package runner 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/go-co-op/gocron/v2" 8 | "github.com/nerijusdu/vesa/pkg/data" 9 | ) 10 | 11 | type JobRunner struct { 12 | scheduler *gocron.Scheduler 13 | logs *data.LogsRepository 14 | } 15 | 16 | func NewJobRunner(logs *data.LogsRepository, initialJobs []data.Job) *JobRunner { 17 | s, err := gocron.NewScheduler() 18 | if err != nil { 19 | panic(err) 20 | } 21 | runner := &JobRunner{ 22 | scheduler: &s, 23 | logs: logs, 24 | } 25 | 26 | for _, job := range initialJobs { 27 | if !job.Enabled { 28 | continue 29 | } 30 | 31 | _, err := s.NewJob( 32 | gocron.CronJob(job.Schedule, true), 33 | gocron.NewTask(runner.runCronJob, job), 34 | gocron.WithTags(job.ID), 35 | ) 36 | 37 | if err != nil { 38 | panic(err) 39 | } 40 | } 41 | 42 | s.Start() 43 | 44 | return runner 45 | } 46 | 47 | func (runner *JobRunner) AddJob(job data.Job) error { 48 | _, err := (*runner.scheduler).NewJob( 49 | gocron.CronJob(job.Schedule, true), 50 | gocron.NewTask(runner.runCronJob, job), 51 | gocron.WithTags(job.ID), 52 | ) 53 | if err != nil { 54 | return err 55 | } 56 | return nil 57 | } 58 | 59 | func (runner *JobRunner) RemoveJob(id string) error { 60 | (*runner.scheduler).RemoveByTags(id) 61 | return nil 62 | } 63 | 64 | func (runner *JobRunner) runCronJob(job data.Job) { 65 | err := runner.logs.Write(job.ID, "Running job "+job.Name) 66 | 67 | httpReq, err := http.NewRequest("GET", job.Url, nil) 68 | if err != nil { 69 | runner.logs.Write(job.ID, "Job failed with error "+err.Error()) 70 | return 71 | } 72 | 73 | if job.Secret != "" { 74 | httpReq.Header.Add("Authorization", "Bearer "+job.Secret) 75 | } 76 | 77 | resp, err := http.DefaultClient.Do(httpReq) 78 | if err != nil { 79 | runner.logs.Write(job.ID, "Job failed with error"+err.Error()) 80 | return 81 | } 82 | 83 | if resp.StatusCode != 200 { 84 | runner.logs.Write( 85 | job.ID, 86 | fmt.Sprintf("Job failed with status code %d", resp.StatusCode), 87 | ) 88 | return 89 | } 90 | 91 | runner.logs.Write(job.ID, "Job finished successfully") 92 | } 93 | -------------------------------------------------------------------------------- /pkg/util/array.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | func Find[T any](arr []T, fn func(T) bool) (T, bool) { 4 | var res T 5 | if arr == nil { 6 | return res, false 7 | } 8 | 9 | for _, v := range arr { 10 | if fn(v) { 11 | return v, true 12 | } 13 | } 14 | 15 | return res, false 16 | } 17 | 18 | func Map[T any, R any](arr []T, fn func(T) R) []R { 19 | if arr == nil { 20 | return nil 21 | } 22 | 23 | b := make([]R, len(arr)) 24 | for i, v := range arr { 25 | b[i] = fn(v) 26 | } 27 | return b 28 | } 29 | 30 | func Sort[T any](arr []T, fn func(T, T) bool) { 31 | if arr == nil { 32 | return 33 | } 34 | 35 | for i := 0; i < len(arr); i++ { 36 | for j := i + 1; j < len(arr); j++ { 37 | if fn(arr[i], arr[j]) { 38 | arr[i], arr[j] = arr[j], arr[i] 39 | } 40 | } 41 | } 42 | } 43 | 44 | func MapDict[T any, K comparable, R any](dict map[K]T, fn func(T) R) map[K]R { 45 | if dict == nil { 46 | return nil 47 | } 48 | 49 | b := make(map[K]R) 50 | for k, v := range dict { 51 | b[k] = fn(v) 52 | } 53 | return b 54 | } 55 | -------------------------------------------------------------------------------- /pkg/util/encoding.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | type FileEncoder interface { 8 | Encode(v any) error 9 | } 10 | 11 | type FileDecoder interface { 12 | Decode(v any) error 13 | } 14 | 15 | type TxtCoder struct { 16 | f *os.File 17 | } 18 | 19 | // I probably dont need this 20 | func NewTxtEncoder(f *os.File) TxtCoder { 21 | return TxtCoder{f} 22 | } 23 | 24 | func (e TxtCoder) Encode(v any) error { 25 | _, err := e.f.WriteString(*v.(*string)) 26 | return err 27 | } 28 | 29 | func (e TxtCoder) Decode(v any) error { 30 | return nil 31 | } 32 | -------------------------------------------------------------------------------- /pkg/util/json.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "os" 7 | "strings" 8 | 9 | "gopkg.in/yaml.v2" 10 | ) 11 | 12 | func GetDataDir() (string, error) { 13 | dirname, err := os.UserHomeDir() 14 | if err != nil { 15 | return "", err 16 | } 17 | 18 | return dirname + "/.config/vesa", nil 19 | } 20 | 21 | func ReadTxtFile(file string) (*string, error) { 22 | dir, err := GetDataDir() 23 | if err != nil { 24 | return nil, err 25 | } 26 | 27 | data, err := os.ReadFile(dir + "/" + file) 28 | if err != nil { 29 | return nil, err 30 | } 31 | 32 | str := string(data) 33 | return &str, nil 34 | } 35 | 36 | func ReadFile[T any](file string) (*T, error) { 37 | dir, err := GetDataDir() 38 | if err != nil { 39 | return nil, err 40 | } 41 | 42 | f, err := os.Open(dir + "/" + file) 43 | if err != nil { 44 | return nil, err 45 | } 46 | defer f.Close() 47 | 48 | var decoder FileDecoder 49 | if strings.HasSuffix(file, ".yaml") { 50 | decoder = yaml.NewDecoder(f) 51 | } else { 52 | decoder = json.NewDecoder(f) 53 | } 54 | 55 | var data T 56 | if err := decoder.Decode(&data); err != nil { 57 | return nil, err 58 | } 59 | 60 | return &data, nil 61 | } 62 | 63 | func CheckImplements[T any, I any]() bool { 64 | var o T 65 | _, ok := interface{}(&o).(I) 66 | return ok 67 | } 68 | 69 | func WriteFile[T any](data *T, file string) error { 70 | dir, err := GetDataDir() 71 | if err != nil { 72 | return err 73 | } 74 | 75 | os.MkdirAll(dir, 0755) 76 | f, err := os.Create(dir + "/" + file) 77 | if err != nil { 78 | return err 79 | } 80 | defer f.Close() 81 | 82 | var encoder FileEncoder 83 | switch any(*data).(type) { 84 | case string: 85 | encoder = NewTxtEncoder(f) 86 | default: 87 | if strings.HasSuffix(file, ".yaml") { 88 | encoder = yaml.NewEncoder(f) 89 | } else if strings.HasSuffix(file, ".json") { 90 | encoder = json.NewEncoder(f) 91 | } else { 92 | encoder = NewTxtEncoder(f) 93 | } 94 | } 95 | 96 | if err := encoder.Encode(data); err != nil { 97 | return err 98 | } 99 | return nil 100 | } 101 | 102 | func AppendToFile(file, data string) error { 103 | dir, err := GetDataDir() 104 | if err != nil { 105 | return err 106 | } 107 | f, err := os.OpenFile(dir+"/"+file, os.O_APPEND|os.O_WRONLY, 0600) 108 | if err != nil { 109 | return err 110 | } 111 | defer f.Close() 112 | if _, err := f.WriteString(data); err != nil { 113 | return err 114 | } 115 | 116 | return nil 117 | } 118 | 119 | func FileExists(path string) bool { 120 | dir, err := GetDataDir() 121 | if err != nil { 122 | panic(err) 123 | } 124 | 125 | _, err = os.Stat(dir + "/" + path) 126 | if err != nil { 127 | if errors.Is(err, os.ErrNotExist) { 128 | return false 129 | } 130 | panic(err) 131 | } 132 | 133 | return true 134 | } 135 | 136 | func CreateEmptyFile(path string) { 137 | dir, err := GetDataDir() 138 | if err != nil { 139 | panic(err) 140 | } 141 | 142 | f, err := os.OpenFile(dir+"/"+path, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600) 143 | if err != nil { 144 | panic(err) 145 | } 146 | 147 | f.Close() 148 | } 149 | -------------------------------------------------------------------------------- /pkg/util/random.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | ) 7 | 8 | var regex = regexp.MustCompile("[^a-z0-9_-]") 9 | 10 | func NormalizeName(name string) string { 11 | newName := strings.ToLower(name) 12 | newName = regex.ReplaceAllString(newName, "_") 13 | 14 | return newName 15 | } 16 | 17 | func BuildTraefikRule(host string, pathPrefixes []string) string { 18 | rules := []string{} 19 | if host != "" { 20 | rules = append(rules, "Host(`"+host+"`)") 21 | } 22 | prefixRules := []string{} 23 | for _, prefix := range pathPrefixes { 24 | if prefix != "" { 25 | prefixRules = append(prefixRules, "PathPrefix(`"+prefix+"`)") 26 | } 27 | } 28 | 29 | if len(prefixRules) > 0 { 30 | rules = append(rules, "("+strings.Join(prefixRules, " || ")+")") 31 | } 32 | 33 | return strings.Join(rules, " && ") 34 | } 35 | -------------------------------------------------------------------------------- /pkg/util/security.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "net/mail" 5 | "time" 6 | 7 | "golang.org/x/crypto/bcrypt" 8 | "golang.org/x/exp/rand" 9 | ) 10 | 11 | func GenerateRandomString(length int) string { 12 | charset := "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789" 13 | seededRand := rand.New(rand.NewSource(uint64(time.Now().UnixNano()))) 14 | b := make([]byte, length) 15 | for i := range b { 16 | b[i] = charset[seededRand.Intn(len(charset))] 17 | } 18 | return string(b) 19 | } 20 | 21 | func HashPassword(password string) (string, error) { 22 | hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 23 | if err != nil { 24 | return "", err 25 | } 26 | return string(hashedPassword), nil 27 | } 28 | 29 | func ComparePassword(hashedPassword, password string) bool { 30 | err := bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password)) 31 | return err == nil 32 | } 33 | 34 | func ValidateEmail(email string) error { 35 | _, err := mail.ParseAddress(email) 36 | return err 37 | } 38 | -------------------------------------------------------------------------------- /pkg/web/api.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "embed" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/http" 9 | 10 | "github.com/go-chi/chi/middleware" 11 | "github.com/go-chi/chi/v5" 12 | "github.com/go-chi/cors" 13 | "github.com/go-chi/oauth" 14 | "github.com/go-playground/validator/v10" 15 | "github.com/nerijusdu/vesa/pkg/config" 16 | "github.com/nerijusdu/vesa/pkg/data" 17 | "github.com/nerijusdu/vesa/pkg/dockerctrl" 18 | "github.com/nerijusdu/vesa/pkg/runner" 19 | ) 20 | 21 | var validate *validator.Validate 22 | 23 | type dockerCtrlClient interface { 24 | Authenticate(req dockerctrl.AuthRequest) (string, error) 25 | 26 | GetContainers(req dockerctrl.GetContainersRequest) ([]dockerctrl.Container, error) 27 | GetContainer(id string) (dockerctrl.ContainerDetails, error) 28 | RunContainer(dockerctrl.RunContainerRequest) (string, error) 29 | DeleteContainer(id string) error 30 | StopContainer(id string) error 31 | StartContainer(id string) error 32 | RestartContainer(id string) error 33 | PullImage(image string) error 34 | GetContainerLogs(id string) (io.ReadCloser, error) 35 | 36 | GetNetworks() ([]dockerctrl.Network, error) 37 | GetNetwork(id string) (dockerctrl.Network, error) 38 | CreateNetwork(dockerctrl.CreateNetworkRequest) (string, error) 39 | RemoveNetwork(id string) error 40 | ConnectNetwork(networkID, containerID string) error 41 | DisconnectNetwork(networkID, containerID string) error 42 | } 43 | 44 | type VesaApi struct { 45 | router chi.Router 46 | publicRouter chi.Router 47 | dockerctrl dockerCtrlClient 48 | projects *data.ProjectsRepository 49 | templates *data.TemplateRepository 50 | apps *data.AppsRepository 51 | traefik *data.TraefikRepository 52 | jobs *data.JobsRepository 53 | runner *runner.JobRunner 54 | logs *data.LogsRepository 55 | auth *data.AuthRepository 56 | config *config.Config 57 | } 58 | 59 | type VesaApiConfig struct { 60 | DockerCtrl dockerCtrlClient 61 | Projects *data.ProjectsRepository 62 | Templates *data.TemplateRepository 63 | Apps *data.AppsRepository 64 | Traefik *data.TraefikRepository 65 | Logs *data.LogsRepository 66 | Jobs *data.JobsRepository 67 | Runner *runner.JobRunner 68 | Auth *data.AuthRepository 69 | Config *config.Config 70 | StaticContent embed.FS 71 | } 72 | 73 | func NewVesaApi(c VesaApiConfig) *VesaApi { 74 | validate = validator.New() 75 | router := chi.NewRouter() 76 | 77 | router.Use(middleware.Logger) 78 | router.Use(middleware.Recoverer) 79 | router.Use(cors.Handler(cors.Options{ 80 | AllowedOrigins: []string{"https://*", "http://*"}, 81 | AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, 82 | AllowedHeaders: []string{"Authorization", "User-Agent", "Content-Type", "Accept", "Accept-Encoding", "Accept-Language", "Cache-Control", "Connection", "DNT", "Host", "Origin", "Pragma", "Referer"}, 83 | ExposedHeaders: []string{"Link"}, 84 | AllowCredentials: true, 85 | MaxAge: 300, 86 | })) 87 | 88 | setupAuth(router, c.Config) 89 | 90 | api := &VesaApi{ 91 | router: router, 92 | publicRouter: router, 93 | dockerctrl: c.DockerCtrl, 94 | projects: c.Projects, 95 | templates: c.Templates, 96 | traefik: c.Traefik, 97 | apps: c.Apps, 98 | auth: c.Auth, 99 | logs: c.Logs, 100 | jobs: c.Jobs, 101 | config: c.Config, 102 | runner: c.Runner, 103 | } 104 | 105 | router.Route("/api", func(r chi.Router) { 106 | r.Group(func(r chi.Router) { 107 | r.Use(oauth.Authorize(c.Config.JWTSecret, nil)) 108 | api.registerContainerRoutes(r) 109 | api.registerNetworkRoutes(r) 110 | api.registerProjectRoutes(r) 111 | api.registerAppRoutes(r) 112 | api.registerTemplateRoutes(r) 113 | api.registerJobsRoutes(r) 114 | }) 115 | r.Group(func(r chi.Router) { 116 | r.Use(AuthorizeApiSecret(r, c.Config)) 117 | api.registerTemplateRoutesWithApiSecret(r) 118 | }) 119 | }) 120 | 121 | fileServer(api.publicRouter, "/", c.StaticContent) 122 | 123 | return api 124 | } 125 | 126 | func (api *VesaApi) ServeHTTP() { 127 | fmt.Println("Listening on port :" + api.config.Port) 128 | log.Fatal(http.ListenAndServe(":"+api.config.Port, api.router)) 129 | } 130 | -------------------------------------------------------------------------------- /pkg/web/appsApi.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/go-chi/chi/v5" 8 | "github.com/nerijusdu/vesa/pkg/data" 9 | "github.com/nerijusdu/vesa/pkg/util" 10 | ) 11 | 12 | func (api *VesaApi) registerAppRoutes(router chi.Router) { 13 | router.Get("/apps", func(w http.ResponseWriter, r *http.Request) { 14 | res, err := api.apps.GetApps() 15 | if err != nil { 16 | handleError(w, err) 17 | return 18 | } 19 | 20 | sendJson(w, res) 21 | }) 22 | 23 | router.Get("/apps/{id}", func(w http.ResponseWriter, r *http.Request) { 24 | res, err := api.apps.GetApp(chi.URLParam(r, "id")) 25 | if err != nil { 26 | handleError(w, err) 27 | return 28 | } 29 | 30 | sendJson(w, res) 31 | }) 32 | 33 | router.Post("/apps", func(w http.ResponseWriter, r *http.Request) { 34 | req := &data.App{} 35 | err := json.NewDecoder(r.Body).Decode(req) 36 | if err != nil { 37 | handleError(w, err) 38 | return 39 | } 40 | 41 | err = validate.Struct(req) 42 | if err != nil { 43 | validationError(w, err) 44 | return 45 | } 46 | 47 | id, oldName, err := api.apps.SaveApp(*req) 48 | if err != nil { 49 | handleError(w, err) 50 | return 51 | } 52 | 53 | rule := util.BuildTraefikRule(req.Domain.Host, req.Domain.PathPrefixes) 54 | middlewares := []string{} 55 | name := util.NormalizeName(req.Name) 56 | traefikConfig, err := api.traefik.GetRoutes() 57 | if err != nil { 58 | handleError(w, err) 59 | return 60 | } 61 | 62 | if req.Domain.StripPrefix && req.Domain.Host != "" { 63 | middlewares = append(middlewares, "strip-prefix-"+name) 64 | traefikConfig.Http.Middlewares["strip-prefix-"+name] = data.TraefikMiddleware{ 65 | StripPrefix: &data.StripPrefixMiddleware{ 66 | Prefixes: req.Domain.PathPrefixes, 67 | }, 68 | } 69 | } else { 70 | delete(traefikConfig.Http.Middlewares, "strip-prefix-"+name) 71 | } 72 | 73 | if len(req.Domain.Headers) > 0 && req.Domain.Host != "" { 74 | middlewares = append(middlewares, "add-headers-"+name) 75 | headers := make(map[string]string) 76 | for _, h := range req.Domain.Headers { 77 | headers[h.Name] = h.Value 78 | } 79 | traefikConfig.Http.Middlewares["add-headers-"+name] = data.TraefikMiddleware{ 80 | Headers: &data.HeadersMiddleware{CustomRequestHeaders: headers}, 81 | } 82 | } 83 | 84 | var services map[string]data.TraefikService 85 | if traefikConfig.Http.Services == nil { 86 | services = make(map[string]data.TraefikService) 87 | } else { 88 | services = *traefikConfig.Http.Services 89 | } 90 | 91 | var routers map[string]data.TraefikRouter 92 | if traefikConfig.Http.Routers == nil { 93 | routers = make(map[string]data.TraefikRouter) 94 | } else { 95 | routers = *traefikConfig.Http.Routers 96 | } 97 | 98 | services[name] = data.TraefikService{ 99 | LoadBalancer: data.TraefikLoadBalancer{ 100 | Servers: []data.TraefikServer{{URL: req.Route}}, 101 | PassHostHeader: true, 102 | }, 103 | } 104 | webRouter := data.TraefikRouter{ 105 | EntryPoints: req.Domain.Entrypoints, 106 | Service: name, 107 | Rule: rule, 108 | Middlewares: middlewares, 109 | } 110 | 111 | if req.Domain.Entrypoints[0] == "websecure" { 112 | webRouter.Tls = &data.TraefikTlsConfig{ 113 | CertResolver: "vesaresolver", 114 | } 115 | routers[name+"-http"] = data.TraefikRouter{ 116 | EntryPoints: []string{"web"}, 117 | Service: name, 118 | Rule: rule, 119 | Middlewares: []string{"redirect-to-https"}, 120 | } 121 | } else { 122 | delete(routers, name+"-http") 123 | } 124 | 125 | routers[name] = webRouter 126 | 127 | if oldName != req.Name { 128 | oldName = util.NormalizeName(oldName) 129 | delete(routers, oldName) 130 | delete(routers, oldName+"-http") 131 | delete(services, oldName) 132 | delete(traefikConfig.Http.Middlewares, "strip-prefix-"+oldName) 133 | delete(traefikConfig.Http.Middlewares, "add-headers-"+oldName) 134 | } 135 | 136 | traefikConfig.Http.Routers = &routers 137 | traefikConfig.Http.Services = &services 138 | 139 | err = api.traefik.SaveRoutes(traefikConfig) 140 | if err != nil { 141 | handleError(w, err) 142 | return 143 | } 144 | 145 | res := &CreatedResponse{Id: id} 146 | 147 | w.WriteHeader(http.StatusCreated) 148 | sendJson(w, res) 149 | }) 150 | 151 | router.Delete("/apps/{id}", func(w http.ResponseWriter, r *http.Request) { 152 | id := chi.URLParam(r, "id") 153 | app, err := api.apps.GetApp(id) 154 | if err != nil { 155 | handleError(w, err) 156 | return 157 | } 158 | 159 | err = api.apps.DeleteApp(id) 160 | if err != nil { 161 | handleError(w, err) 162 | return 163 | } 164 | 165 | traefikConfig, err := api.traefik.GetRoutes() 166 | routers := *traefikConfig.Http.Routers 167 | services := *traefikConfig.Http.Services 168 | name := util.NormalizeName(app.Name) 169 | if routers != nil { 170 | delete(routers, name) 171 | delete(routers, name+"-http") 172 | } 173 | if services != nil { 174 | delete(services, name) 175 | } 176 | delete(traefikConfig.Http.Middlewares, "strip-prefix-"+name) 177 | delete(traefikConfig.Http.Middlewares, "add-headers-"+name) 178 | 179 | err = api.traefik.SaveRoutes(traefikConfig) 180 | if err != nil { 181 | handleError(w, err) 182 | return 183 | } 184 | 185 | w.WriteHeader(http.StatusNoContent) 186 | }) 187 | } 188 | -------------------------------------------------------------------------------- /pkg/web/auth.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "strings" 8 | "time" 9 | 10 | "github.com/go-chi/chi/v5" 11 | "github.com/go-chi/oauth" 12 | "github.com/nerijusdu/vesa/pkg/config" 13 | "github.com/nerijusdu/vesa/pkg/util" 14 | ) 15 | 16 | func setupAuth(router chi.Router, c *config.Config) { 17 | s := oauth.NewBearerServer( 18 | c.JWTSecret, 19 | time.Hour*24, 20 | &UserVerifier{config: c}, 21 | nil, 22 | ) 23 | 24 | router.Post("/api/token", s.UserCredentials) 25 | router.Post("/api/auth", s.ClientCredentials) 26 | } 27 | 28 | type UserVerifier struct { 29 | config *config.Config 30 | } 31 | 32 | func AuthorizeApiSecret(router chi.Router, c *config.Config) func(next http.Handler) http.Handler { 33 | return func(next http.Handler) http.Handler { 34 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 35 | auth, err := parseAuthorizationHeader(r.Header.Get("Authorization")) 36 | if err != nil { 37 | w.WriteHeader(http.StatusUnauthorized) 38 | w.Write([]byte(err.Error())) 39 | return 40 | } 41 | 42 | c, ok := util.Find(c.Clients, func(c config.Client) bool { 43 | return util.ComparePassword(c.Secret, auth) 44 | }) 45 | 46 | if !ok { 47 | w.WriteHeader(http.StatusUnauthorized) 48 | w.Write([]byte("Unauthorized")) 49 | return 50 | } 51 | 52 | fmt.Println("Authorizing API for client: " + c.ID) 53 | 54 | next.ServeHTTP(w, r) 55 | }) 56 | } 57 | } 58 | 59 | func parseAuthorizationHeader(auth string) (string, error) { 60 | if len(auth) < 7 { 61 | return "", errors.New("Invalid bearer authorization header") 62 | } 63 | 64 | authType := strings.ToLower(auth[:6]) 65 | if authType != "bearer" { 66 | return "", errors.New("Invalid bearer authorization header") 67 | } 68 | 69 | auth = strings.Replace(strings.ToLower(auth), "bearer ", "", 1) 70 | return auth, nil 71 | } 72 | 73 | func (uv *UserVerifier) ValidateUser(username, password, scope string, r *http.Request) error { 74 | if username == uv.config.UserName && util.ComparePassword(uv.config.Password, password) { 75 | return nil 76 | } 77 | 78 | return errors.New("wrong user") 79 | } 80 | 81 | func (uv *UserVerifier) ValidateClient(clientID, clientSecret, scope string, r *http.Request) error { 82 | _, ok := util.Find(uv.config.Clients, func(c config.Client) bool { 83 | return c.ID == clientID && util.ComparePassword(c.Secret, clientSecret) 84 | }) 85 | 86 | if ok { 87 | return nil 88 | } 89 | 90 | return errors.New("wrong client") 91 | } 92 | 93 | func (*UserVerifier) ValidateCode(clientID, clientSecret, code, redirectURI string, r *http.Request) (string, error) { 94 | return "", nil 95 | } 96 | 97 | func (*UserVerifier) AddClaims(tokenType oauth.TokenType, credential, tokenID, scope string, r *http.Request) (map[string]string, error) { 98 | claims := make(map[string]string) 99 | return claims, nil 100 | } 101 | 102 | func (*UserVerifier) AddProperties(tokenType oauth.TokenType, credential, tokenID, scope string, r *http.Request) (map[string]string, error) { 103 | props := make(map[string]string) 104 | return props, nil 105 | } 106 | 107 | func (*UserVerifier) ValidateTokenID(tokenType oauth.TokenType, credential, tokenID, refreshTokenID string) error { 108 | return errors.New("refresh token not allowed") 109 | } 110 | 111 | func (*UserVerifier) StoreTokenID(tokenType oauth.TokenType, credential, tokenID, refreshTokenID string) error { 112 | return nil 113 | } 114 | -------------------------------------------------------------------------------- /pkg/web/containerApi.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "io" 7 | "net/http" 8 | "net/url" 9 | 10 | "github.com/go-chi/chi/v5" 11 | "github.com/nerijusdu/vesa/pkg/config" 12 | "github.com/nerijusdu/vesa/pkg/data" 13 | "github.com/nerijusdu/vesa/pkg/dockerctrl" 14 | "github.com/nerijusdu/vesa/pkg/util" 15 | ) 16 | 17 | func (api *VesaApi) registerContainerRoutes(router chi.Router) { 18 | router.Get("/containers", func(w http.ResponseWriter, r *http.Request) { 19 | label, _ := url.QueryUnescape(r.URL.Query().Get("label")) 20 | res, err := api.dockerctrl.GetContainers(dockerctrl.GetContainersRequest{ 21 | Label: label, 22 | }) 23 | if err != nil { 24 | handleError(w, err) 25 | return 26 | } 27 | 28 | sendJson(w, res) 29 | }) 30 | 31 | router.Post("/containers", func(w http.ResponseWriter, r *http.Request) { 32 | req := &dockerctrl.RunContainerRequest{} 33 | err := json.NewDecoder(r.Body).Decode(req) 34 | if err != nil { 35 | handleError(w, err) 36 | return 37 | } 38 | 39 | err = validate.Struct(req) 40 | if err != nil { 41 | validationError(w, err) 42 | return 43 | } 44 | 45 | var tId string 46 | if req.SaveAsTemplate { 47 | tId, err = api.templates.SaveTemplate(data.Template{Container: *req}) 48 | if err != nil { 49 | handleError(w, err) 50 | return 51 | } 52 | 53 | if req.Labels == nil { 54 | req.Labels = make(map[string]string) 55 | } 56 | req.Labels["template"] = tId 57 | } 58 | 59 | id, err := api.dockerctrl.RunContainer(*req) 60 | if err != nil { 61 | if tId != "" { 62 | api.templates.DeleteTemplate(tId) 63 | } 64 | handleError(w, err) 65 | return 66 | } 67 | 68 | res := &CreatedResponse{Id: id} 69 | 70 | w.WriteHeader(http.StatusCreated) 71 | sendJson(w, res) 72 | }) 73 | 74 | router.Get("/containers/{id}", func(w http.ResponseWriter, r *http.Request) { 75 | id := chi.URLParam(r, "id") 76 | res, err := api.dockerctrl.GetContainer(id) 77 | if err != nil { 78 | handleError(w, err) 79 | return 80 | } 81 | 82 | sendJson(w, res) 83 | }) 84 | 85 | router.Delete("/containers/{id}", func(w http.ResponseWriter, r *http.Request) { 86 | id := chi.URLParam(r, "id") 87 | err := api.dockerctrl.DeleteContainer(id) 88 | if err != nil { 89 | handleError(w, err) 90 | return 91 | } 92 | 93 | w.WriteHeader(http.StatusNoContent) 94 | }) 95 | 96 | router.Post("/containers/{id}/stop", func(w http.ResponseWriter, r *http.Request) { 97 | id := chi.URLParam(r, "id") 98 | err := api.dockerctrl.StopContainer(id) 99 | if err != nil { 100 | handleError(w, err) 101 | return 102 | } 103 | 104 | w.WriteHeader(http.StatusNoContent) 105 | }) 106 | 107 | router.Post("/containers/{id}/start", func(w http.ResponseWriter, r *http.Request) { 108 | id := chi.URLParam(r, "id") 109 | err := api.dockerctrl.StartContainer(id) 110 | if err != nil { 111 | handleError(w, err) 112 | return 113 | } 114 | 115 | w.WriteHeader(http.StatusNoContent) 116 | }) 117 | 118 | router.Post("/containers/{id}/restart", func(w http.ResponseWriter, r *http.Request) { 119 | id := chi.URLParam(r, "id") 120 | err := api.dockerctrl.RestartContainer(id) 121 | if err != nil { 122 | handleError(w, err) 123 | return 124 | } 125 | 126 | w.WriteHeader(http.StatusNoContent) 127 | }) 128 | 129 | router.Get("/containers/{id}/logs", func(w http.ResponseWriter, r *http.Request) { 130 | id := chi.URLParam(r, "id") 131 | reader, err := api.dockerctrl.GetContainerLogs(id) 132 | if err != nil { 133 | handleError(w, err) 134 | return 135 | } 136 | defer reader.Close() 137 | 138 | w.WriteHeader(http.StatusOK) 139 | 140 | header := make([]byte, 8) 141 | for { 142 | n, err := reader.Read(header) 143 | if err != nil { 144 | if err != io.EOF { 145 | handleError(w, err) 146 | } 147 | return 148 | } 149 | if n <= 0 { 150 | break 151 | } 152 | 153 | // isErr := header[0] == byte(2) 154 | size := uint32(header[4])<<24 | 155 | uint32(header[5])<<16 | 156 | uint32(header[6])<<8 | 157 | uint32(header[7]) 158 | 159 | buf := make([]byte, size) 160 | n, err = reader.Read(buf) 161 | if err != nil { 162 | if err != io.EOF { 163 | handleError(w, err) 164 | } 165 | return 166 | } 167 | 168 | w.Write(buf) 169 | } 170 | }) 171 | 172 | router.Post("/docker/auth", func(w http.ResponseWriter, r *http.Request) { 173 | req := &dockerctrl.AuthRequest{} 174 | err := json.NewDecoder(r.Body).Decode(req) 175 | if err != nil { 176 | handleError(w, err) 177 | return 178 | } 179 | 180 | err = validate.Struct(req) 181 | if err != nil { 182 | validationError(w, err) 183 | return 184 | } 185 | 186 | token, err := api.dockerctrl.Authenticate(*req) 187 | if err != nil { 188 | handleError(w, err) 189 | return 190 | } 191 | 192 | if token == "" { 193 | v, err := json.Marshal(req) 194 | if err != nil { 195 | handleError(w, err) 196 | return 197 | } 198 | token = base64.StdEncoding.EncodeToString(v) 199 | } 200 | 201 | err = api.auth.SaveAuth(data.RegistryAuth{ 202 | IdentityToken: token, 203 | ServerAddress: req.ServerAddress, 204 | }) 205 | if err != nil { 206 | handleError(w, err) 207 | return 208 | } 209 | 210 | w.WriteHeader(http.StatusOK) 211 | }) 212 | 213 | router.Get("/docker/auth", func(w http.ResponseWriter, r *http.Request) { 214 | auths, err := api.auth.GetAuths() 215 | if err != nil { 216 | handleError(w, err) 217 | return 218 | } 219 | 220 | for i := range auths { 221 | auths[i].IdentityToken = "" 222 | } 223 | 224 | sendJson(w, auths) 225 | }) 226 | 227 | router.Get("/settings/clients", func(w http.ResponseWriter, r *http.Request) { 228 | clients := []string{} 229 | for _, v := range api.config.Clients { 230 | clients = append(clients, v.ID) 231 | } 232 | 233 | sendJson(w, clients) 234 | }) 235 | 236 | router.Post("/settings/clients", func(w http.ResponseWriter, r *http.Request) { 237 | req := &dockerctrl.CreateClientRequest{} 238 | err := json.NewDecoder(r.Body).Decode(req) 239 | if err != nil { 240 | handleError(w, err) 241 | return 242 | } 243 | 244 | err = validate.Struct(req) 245 | if err != nil { 246 | validationError(w, err) 247 | return 248 | } 249 | 250 | key, err := util.HashPassword(req.ClientSecret) 251 | if err != nil { 252 | handleError(w, err) 253 | return 254 | } 255 | 256 | newConfig, err := config.AddClient(api.config, req.ClientID, key) 257 | if err != nil { 258 | handleError(w, err) 259 | return 260 | } 261 | 262 | api.config = newConfig 263 | 264 | w.WriteHeader(http.StatusOK) 265 | }) 266 | 267 | router.Delete("/settings/clients/{id}", func(w http.ResponseWriter, r *http.Request) { 268 | id := chi.URLParam(r, "id") 269 | newConfig, err := config.RemoveClient(api.config, id) 270 | if err != nil { 271 | handleError(w, err) 272 | return 273 | } 274 | 275 | api.config = newConfig 276 | 277 | w.WriteHeader(http.StatusOK) 278 | }) 279 | } 280 | -------------------------------------------------------------------------------- /pkg/web/fileServer.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "embed" 5 | "io/fs" 6 | "net/http" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/go-chi/chi/v5" 12 | ) 13 | 14 | func fileServer(r chi.Router, path string, content embed.FS) { 15 | root, err := fs.Sub(content, "public") 16 | if err != nil { 17 | panic(err) 18 | } 19 | if strings.ContainsAny(path, "{}*") { 20 | panic("FileServer does not permit any URL parameters.") 21 | } 22 | 23 | if path != "/" && path[len(path)-1] != '/' { 24 | r.Get(path, http.RedirectHandler(path+"/", http.StatusMovedPermanently).ServeHTTP) 25 | path += "/" 26 | } 27 | path += "*" 28 | publicPath := "public" 29 | index, err := content.ReadFile(filepath.Join(publicPath, "index.html")) 30 | if err != nil { 31 | panic(err) 32 | } 33 | 34 | r.NotFound(func(w http.ResponseWriter, r *http.Request) { 35 | w.WriteHeader(http.StatusAccepted) 36 | w.Write(index) 37 | }) 38 | 39 | r.Get(path, func(w http.ResponseWriter, r *http.Request) { 40 | f, err := content.Open(filepath.Join(publicPath, r.URL.Path)) 41 | if os.IsNotExist(err) { 42 | w.WriteHeader(http.StatusAccepted) 43 | w.Write(index) 44 | return 45 | } else if err != nil { 46 | http.Error(w, err.Error(), http.StatusInternalServerError) 47 | return 48 | } 49 | defer f.Close() 50 | 51 | http.FileServer(http.FS(root)).ServeHTTP(w, r) 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /pkg/web/helpers.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | 8 | "github.com/go-playground/validator/v10" 9 | ) 10 | 11 | func handleError(w http.ResponseWriter, err error) { 12 | log.Println(err) 13 | w.WriteHeader(http.StatusInternalServerError) 14 | w.Write([]byte(err.Error())) 15 | } 16 | 17 | func validationError(w http.ResponseWriter, err error) { 18 | validationErrors := err.(validator.ValidationErrors) 19 | w.WriteHeader(http.StatusBadRequest) 20 | sendJson(w, &ErrorResponse{ 21 | Message: "Validation error", 22 | Type: "validation", 23 | Errors: mapValidationErrors(validationErrors), 24 | }) 25 | } 26 | 27 | func sendJson(w http.ResponseWriter, data any) { 28 | jsonData, err := json.Marshal(data) 29 | if err != nil { 30 | handleError(w, err) 31 | return 32 | } 33 | 34 | w.Header().Set("Content-Type", "application/json") 35 | w.Write(jsonData) 36 | } 37 | 38 | func mapValidationErrors(validationErrors validator.ValidationErrors) map[string]string { 39 | errors := make(map[string]string) 40 | for _, err := range validationErrors { 41 | errors[err.Namespace()] = err.Tag() 42 | } 43 | return errors 44 | } 45 | -------------------------------------------------------------------------------- /pkg/web/jobsApi.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/go-chi/chi/v5" 9 | "github.com/nerijusdu/vesa/pkg/data" 10 | ) 11 | 12 | func (api *VesaApi) registerJobsRoutes(router chi.Router) { 13 | router.Get("/jobs", func(w http.ResponseWriter, r *http.Request) { 14 | res, err := api.jobs.GetJobs() 15 | if err != nil { 16 | handleError(w, err) 17 | return 18 | } 19 | 20 | for i := range res { 21 | res[i].Secret = "" 22 | } 23 | 24 | sendJson(w, res) 25 | }) 26 | 27 | router.Get("/jobs/{id}", func(w http.ResponseWriter, r *http.Request) { 28 | res, err := api.jobs.GetJob(chi.URLParam(r, "id")) 29 | if err != nil { 30 | handleError(w, err) 31 | return 32 | } 33 | 34 | sendJson(w, res) 35 | }) 36 | 37 | router.Get("/jobs/{id}/logs", func(w http.ResponseWriter, r *http.Request) { 38 | logs, err := api.logs.GetLogs(chi.URLParam(r, "id")) 39 | if err != nil { 40 | handleError(w, err) 41 | return 42 | } 43 | 44 | sendJson(w, strings.Join(logs, "\n")) 45 | }) 46 | 47 | router.Delete("/jobs/{id}/logs", func(w http.ResponseWriter, r *http.Request) { 48 | err := api.logs.DeleteLogs(chi.URLParam(r, "id")) 49 | if err != nil { 50 | handleError(w, err) 51 | return 52 | } 53 | 54 | w.WriteHeader(http.StatusNoContent) 55 | }) 56 | 57 | router.Post("/jobs", func(w http.ResponseWriter, r *http.Request) { 58 | req := &data.Job{} 59 | err := json.NewDecoder(r.Body).Decode(req) 60 | if err != nil { 61 | handleError(w, err) 62 | return 63 | } 64 | 65 | err = validate.Struct(req) 66 | if err != nil { 67 | validationError(w, err) 68 | return 69 | } 70 | 71 | if req.ID != "" { 72 | err := api.runner.RemoveJob(req.ID) 73 | if err != nil { 74 | handleError(w, err) 75 | return 76 | } 77 | } 78 | 79 | id, err := api.jobs.SaveJob(*req) 80 | if err != nil { 81 | handleError(w, err) 82 | return 83 | } 84 | 85 | req.ID = id 86 | 87 | if req.Enabled { 88 | err = api.runner.AddJob(*req) 89 | if err != nil { 90 | handleError(w, err) 91 | return 92 | } 93 | } 94 | 95 | sendJson(w, req) 96 | }) 97 | 98 | router.Delete("/jobs/{id}", func(w http.ResponseWriter, r *http.Request) { 99 | id := chi.URLParam(r, "id") 100 | 101 | err := api.runner.RemoveJob(id) 102 | if err != nil { 103 | handleError(w, err) 104 | return 105 | } 106 | 107 | err = api.jobs.DeleteJob(id) 108 | if err != nil { 109 | handleError(w, err) 110 | return 111 | } 112 | 113 | w.WriteHeader(http.StatusNoContent) 114 | }) 115 | } 116 | -------------------------------------------------------------------------------- /pkg/web/models.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import "github.com/nerijusdu/vesa/pkg/dockerctrl" 4 | 5 | type CreatedResponse struct { 6 | Id string `json:"id"` 7 | } 8 | 9 | type ConnectNetworkRequest struct { 10 | ContainerId string `json:"containerId" validate:"required"` 11 | } 12 | 13 | type SaveTemplateRequest struct { 14 | Id string `json:"id"` 15 | Container dockerctrl.RunContainerRequest `json:"container,omitempty" validate:"-"` 16 | ContainerId string `json:"containerId"` 17 | } 18 | 19 | type ErrorResponse struct { 20 | Message string `json:"message"` 21 | Type string `json:"type"` 22 | Errors map[string]string `json:"errors"` 23 | } 24 | -------------------------------------------------------------------------------- /pkg/web/networkApi.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/go-chi/chi/v5" 8 | "github.com/nerijusdu/vesa/pkg/dockerctrl" 9 | ) 10 | 11 | func (api *VesaApi) registerNetworkRoutes(router chi.Router) { 12 | router.Get("/networks", func(w http.ResponseWriter, r *http.Request) { 13 | res, err := api.dockerctrl.GetNetworks() 14 | if err != nil { 15 | handleError(w, err) 16 | return 17 | } 18 | 19 | sendJson(w, res) 20 | }) 21 | 22 | router.Get("/networks/{id}", func(w http.ResponseWriter, r *http.Request) { 23 | id := chi.URLParam(r, "id") 24 | res, err := api.dockerctrl.GetNetwork(id) 25 | if err != nil { 26 | handleError(w, err) 27 | return 28 | } 29 | 30 | sendJson(w, res) 31 | }) 32 | 33 | router.Post("/networks", func(w http.ResponseWriter, r *http.Request) { 34 | req := &dockerctrl.CreateNetworkRequest{} 35 | err := json.NewDecoder(r.Body).Decode(req) 36 | if err != nil { 37 | handleError(w, err) 38 | return 39 | } 40 | 41 | err = validate.Struct(req) 42 | if err != nil { 43 | handleError(w, err) 44 | return 45 | } 46 | 47 | id, err := api.dockerctrl.CreateNetwork(*req) 48 | if err != nil { 49 | handleError(w, err) 50 | return 51 | } 52 | 53 | res := &CreatedResponse{Id: id} 54 | 55 | w.WriteHeader(http.StatusCreated) 56 | sendJson(w, res) 57 | }) 58 | 59 | router.Delete("/networks/{id}", func(w http.ResponseWriter, r *http.Request) { 60 | id := chi.URLParam(r, "id") 61 | err := api.dockerctrl.RemoveNetwork(id) 62 | if err != nil { 63 | handleError(w, err) 64 | return 65 | } 66 | 67 | w.WriteHeader(http.StatusNoContent) 68 | }) 69 | 70 | router.Post("/networks/{id}/connect", func(w http.ResponseWriter, r *http.Request) { 71 | networkId := chi.URLParam(r, "id") 72 | req := &ConnectNetworkRequest{} 73 | err := json.NewDecoder(r.Body).Decode(req) 74 | if err != nil { 75 | handleError(w, err) 76 | return 77 | } 78 | 79 | err = validate.Struct(req) 80 | if err != nil { 81 | handleError(w, err) 82 | return 83 | } 84 | 85 | err = api.dockerctrl.ConnectNetwork(networkId, req.ContainerId) 86 | if err != nil { 87 | handleError(w, err) 88 | return 89 | } 90 | 91 | w.WriteHeader(http.StatusNoContent) 92 | }) 93 | 94 | router.Post("/networks/{id}/disconnect", func(w http.ResponseWriter, r *http.Request) { 95 | networkId := chi.URLParam(r, "id") 96 | req := &ConnectNetworkRequest{} 97 | err := json.NewDecoder(r.Body).Decode(req) 98 | if err != nil { 99 | handleError(w, err) 100 | return 101 | } 102 | 103 | err = validate.Struct(req) 104 | if err != nil { 105 | handleError(w, err) 106 | return 107 | } 108 | 109 | err = api.dockerctrl.DisconnectNetwork(networkId, req.ContainerId) 110 | if err != nil { 111 | handleError(w, err) 112 | return 113 | } 114 | 115 | w.WriteHeader(http.StatusNoContent) 116 | }) 117 | } 118 | -------------------------------------------------------------------------------- /pkg/web/projectApi.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "encoding/json" 5 | "net/http" 6 | 7 | "github.com/go-chi/chi/v5" 8 | "github.com/nerijusdu/vesa/pkg/data" 9 | "github.com/nerijusdu/vesa/pkg/dockerctrl" 10 | ) 11 | 12 | func (api *VesaApi) registerProjectRoutes(router chi.Router) { 13 | router.Get("/projects", func(w http.ResponseWriter, r *http.Request) { 14 | res, err := api.projects.GetProjects() 15 | if err != nil { 16 | handleError(w, err) 17 | return 18 | } 19 | 20 | sendJson(w, res) 21 | }) 22 | 23 | router.Get("/projects/{id}", func(w http.ResponseWriter, r *http.Request) { 24 | id := chi.URLParam(r, "id") 25 | res, err := api.projects.GetProject(id) 26 | if err != nil { 27 | handleError(w, err) 28 | return 29 | } 30 | 31 | sendJson(w, res) 32 | }) 33 | 34 | router.Post("/projects", func(w http.ResponseWriter, r *http.Request) { 35 | req := &data.Project{} 36 | err := json.NewDecoder(r.Body).Decode(req) 37 | if err != nil { 38 | handleError(w, err) 39 | return 40 | } 41 | 42 | err = validate.Struct(req) 43 | if err != nil { 44 | handleError(w, err) 45 | return 46 | } 47 | 48 | if req.NetworkId == "" { 49 | networkId, err := api.dockerctrl.CreateNetwork(dockerctrl.CreateNetworkRequest{ 50 | Name: req.NetworkName, 51 | Driver: "bridge", 52 | }) 53 | 54 | if err != nil { 55 | handleError(w, err) 56 | return 57 | } 58 | 59 | req.NetworkId = networkId 60 | } 61 | 62 | if req.NetworkName == "" { 63 | network, err := api.dockerctrl.GetNetwork(req.NetworkId) 64 | if err != nil { 65 | handleError(w, err) 66 | return 67 | } 68 | 69 | req.NetworkName = network.Name 70 | } 71 | 72 | for _, containerId := range req.Containers { 73 | container, err := api.dockerctrl.GetContainer(containerId) 74 | if err != nil { 75 | handleError(w, err) 76 | return 77 | } 78 | 79 | if _, found := container.NetworkSettings.Networks[req.NetworkName]; found { 80 | continue 81 | } 82 | 83 | err = api.dockerctrl.ConnectNetwork(req.NetworkId, containerId) 84 | 85 | if err != nil { 86 | handleError(w, err) 87 | return 88 | } 89 | } 90 | 91 | id, err := api.projects.SaveProject(*req) 92 | if err != nil { 93 | handleError(w, err) 94 | return 95 | } 96 | 97 | w.WriteHeader(http.StatusCreated) 98 | sendJson(w, &CreatedResponse{Id: id}) 99 | }) 100 | 101 | router.Delete("/projects/{id}", func(w http.ResponseWriter, r *http.Request) { 102 | id := chi.URLParam(r, "id") 103 | err := api.projects.DeleteProject(id) 104 | if err != nil { 105 | handleError(w, err) 106 | return 107 | } 108 | 109 | w.WriteHeader(http.StatusNoContent) 110 | }) 111 | 112 | router.Post("/projects/{id}/start", func(w http.ResponseWriter, r *http.Request) { 113 | id := chi.URLParam(r, "id") 114 | project, err := api.projects.GetProject(id) 115 | if err != nil { 116 | handleError(w, err) 117 | return 118 | } 119 | 120 | for _, containerId := range project.Containers { 121 | err = api.dockerctrl.StartContainer(containerId) 122 | if err != nil { 123 | handleError(w, err) 124 | return 125 | } 126 | } 127 | 128 | w.WriteHeader(http.StatusNoContent) 129 | }) 130 | 131 | router.Post("/projects/{id}/stop", func(w http.ResponseWriter, r *http.Request) { 132 | id := chi.URLParam(r, "id") 133 | project, err := api.projects.GetProject(id) 134 | if err != nil { 135 | handleError(w, err) 136 | return 137 | } 138 | 139 | for _, containerId := range project.Containers { 140 | err = api.dockerctrl.StopContainer(containerId) 141 | if err != nil { 142 | handleError(w, err) 143 | return 144 | } 145 | } 146 | 147 | w.WriteHeader(http.StatusNoContent) 148 | }) 149 | 150 | router.Post("/projects/{id}/pull", func(w http.ResponseWriter, r *http.Request) { 151 | id := chi.URLParam(r, "id") 152 | project, err := api.projects.GetProject(id) 153 | if err != nil { 154 | handleError(w, err) 155 | return 156 | } 157 | 158 | for _, containerId := range project.Containers { 159 | container, err := api.dockerctrl.GetContainer(containerId) 160 | if err != nil { 161 | handleError(w, err) 162 | return 163 | } 164 | 165 | err = api.dockerctrl.PullImage(container.Config.Image) 166 | if err != nil { 167 | handleError(w, err) 168 | return 169 | } 170 | } 171 | 172 | w.WriteHeader(http.StatusNoContent) 173 | }) 174 | } 175 | -------------------------------------------------------------------------------- /pkg/web/templateApi.go: -------------------------------------------------------------------------------- 1 | package web 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "net/http" 7 | "net/url" 8 | "strings" 9 | 10 | "github.com/go-chi/chi/v5" 11 | "github.com/nerijusdu/vesa/pkg/data" 12 | "github.com/nerijusdu/vesa/pkg/dockerctrl" 13 | ) 14 | 15 | func (api *VesaApi) registerTemplateRoutes(router chi.Router) { 16 | router.Get("/templates", func(w http.ResponseWriter, r *http.Request) { 17 | res, err := api.templates.GetTemplates() 18 | if err != nil { 19 | handleError(w, err) 20 | return 21 | } 22 | 23 | sendJson(w, res) 24 | }) 25 | 26 | router.Get("/templates/{id}", func(w http.ResponseWriter, r *http.Request) { 27 | res, err := api.templates.GetTemplate(chi.URLParam(r, "id")) 28 | if err != nil { 29 | handleError(w, err) 30 | return 31 | } 32 | 33 | sendJson(w, res) 34 | }) 35 | 36 | router.Post("/templates", func(w http.ResponseWriter, r *http.Request) { 37 | req := &SaveTemplateRequest{} 38 | err := json.NewDecoder(r.Body).Decode(req) 39 | if err != nil { 40 | handleError(w, err) 41 | return 42 | } 43 | 44 | err = validate.Struct(req) 45 | if err != nil { 46 | validationError(w, err) 47 | return 48 | } 49 | 50 | if req.ContainerId != "" { 51 | c, err := api.dockerctrl.GetContainer(req.ContainerId) 52 | if err != nil { 53 | handleError(w, err) 54 | return 55 | } 56 | 57 | req.Container = dockerctrl.MapContainerToRequest(c) 58 | } 59 | 60 | id, err := api.templates.SaveTemplate(data.Template{ 61 | ID: req.Id, 62 | Container: req.Container, 63 | }) 64 | if err != nil { 65 | handleError(w, err) 66 | return 67 | } 68 | 69 | res := &CreatedResponse{Id: id} 70 | 71 | w.WriteHeader(http.StatusCreated) 72 | sendJson(w, res) 73 | }) 74 | 75 | router.Delete("/templates/{id}", func(w http.ResponseWriter, r *http.Request) { 76 | err := api.templates.DeleteTemplate(chi.URLParam(r, "id")) 77 | if err != nil { 78 | handleError(w, err) 79 | return 80 | } 81 | 82 | w.WriteHeader(http.StatusNoContent) 83 | }) 84 | 85 | router.Post("/templates/{id}/use", func(w http.ResponseWriter, r *http.Request) { 86 | template, err := api.templates.GetTemplate(chi.URLParam(r, "id")) 87 | if err != nil { 88 | handleError(w, err) 89 | return 90 | } 91 | 92 | id, err := useTemplate(api, template) 93 | if err != nil { 94 | handleError(w, err) 95 | return 96 | } 97 | 98 | w.WriteHeader(http.StatusCreated) 99 | sendJson(w, &CreatedResponse{Id: id}) 100 | }) 101 | 102 | router.Post("/templates/{id}/update-web", api.handleTemplateUpdate) 103 | } 104 | 105 | func (api *VesaApi) registerTemplateRoutesWithApiSecret(router chi.Router) { 106 | router.Post("/templates/{id}/update", api.handleTemplateUpdate) 107 | } 108 | 109 | func (api *VesaApi) handleTemplateUpdate(w http.ResponseWriter, r *http.Request) { 110 | template, err := api.templates.GetTemplate(chi.URLParam(r, "id")) 111 | if err != nil { 112 | handleError(w, err) 113 | return 114 | } 115 | 116 | tag, err := url.QueryUnescape(r.URL.Query().Get("tag")) 117 | if err != nil || tag == "" { 118 | fmt.Println("Tag cound not be identified, using latest") 119 | tag = "latest" 120 | } 121 | 122 | splits := strings.Split(template.Container.Image, ":") 123 | if len(splits) > 1 { 124 | // fist split is a url with port 125 | if strings.Contains(splits[len(splits)-1], "/") { 126 | splits = append(splits, tag) 127 | } else { 128 | splits[len(splits)-1] = tag 129 | } 130 | } 131 | 132 | template.Container.Image = strings.Join(splits, ":") 133 | if !template.Container.IsLocal { 134 | err = api.dockerctrl.PullImage(template.Container.Image) 135 | if err != nil { 136 | handleError(w, err) 137 | return 138 | } 139 | } 140 | 141 | containers, err := api.dockerctrl.GetContainers(dockerctrl.GetContainersRequest{ 142 | Label: "template=" + template.ID, 143 | }) 144 | if err != nil { 145 | handleError(w, err) 146 | return 147 | } 148 | 149 | if len(containers) > 0 { 150 | c := containers[0] 151 | err = api.dockerctrl.StopContainer(c.ID) 152 | if err != nil { 153 | handleError(w, err) 154 | return 155 | } 156 | 157 | err = api.dockerctrl.DeleteContainer(c.ID) 158 | if err != nil { 159 | handleError(w, err) 160 | return 161 | } 162 | } 163 | 164 | _, err = useTemplate(api, template) 165 | if err != nil { 166 | handleError(w, err) 167 | return 168 | } 169 | 170 | w.WriteHeader(http.StatusOK) 171 | } 172 | 173 | func useTemplate(api *VesaApi, template data.Template) (string, error) { 174 | if template.Container.Labels == nil { 175 | template.Container.Labels = make(map[string]string) 176 | } 177 | template.Container.Labels["template"] = template.ID 178 | 179 | runningContainers, err := api.dockerctrl.GetContainers(dockerctrl.GetContainersRequest{ 180 | Label: "template=" + template.ID, 181 | }) 182 | 183 | if err != nil { 184 | return "", err 185 | } 186 | 187 | if len(runningContainers) > 0 { 188 | if runningContainers[0].State != "exited" { 189 | err = api.dockerctrl.StopContainer(runningContainers[0].ID) 190 | if err != nil { 191 | return "", err 192 | } 193 | } 194 | 195 | err = api.dockerctrl.DeleteContainer(runningContainers[0].ID) 196 | if err != nil { 197 | return "", err 198 | } 199 | } 200 | 201 | return api.dockerctrl.RunContainer(template.Container) 202 | } 203 | -------------------------------------------------------------------------------- /public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | VESA 8 | 9 | 10 | 11 |
12 | 13 | 14 | 15 | -------------------------------------------------------------------------------- /public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /templates/traefik.json: -------------------------------------------------------------------------------- 1 | { 2 | "image": "traefik:v3.1", 3 | "name": "traefik", 4 | "command": "", 5 | "isLocal": false, 6 | "networkId": "", 7 | "saveAsTemplate": true, 8 | "restartPolicy": { 9 | "name": "always", 10 | "maximumRetryCount": 0 11 | }, 12 | "labels": null, 13 | "domain": { 14 | "host": "", 15 | "entrypoints": [ 16 | "" 17 | ] 18 | }, 19 | "mounts": [ 20 | { 21 | "type": "bind", 22 | "source": "/var/run/docker.sock", 23 | "target": "/var/run/docker.sock", 24 | "name": "" 25 | }, 26 | { 27 | "type": "bind", 28 | "source": "{{.ConfigDir}}/acme_json", 29 | "target": "/acme.json", 30 | "name": "" 31 | }, 32 | { 33 | "type": "bind", 34 | "source": "{{.ConfigDir}}/routes.yaml", 35 | "target": "/routes.yaml", 36 | "name": "" 37 | } 38 | ], 39 | "ports": [ 40 | "8080:8080", 41 | "80:80", 42 | "443:443" 43 | ], 44 | "envVars": [ 45 | "TRAEFIK_API_INSECURE={{ .EnableDashboard }}", 46 | "TRAEFIK_PROVIDERS_FILE_FILENAME=/routes.yaml", 47 | "TRAEFIK_PROVIDERS_DOCKER=true", 48 | "TRAEFIK_ENTRYPOINTS_web_ADDRESS=:80", 49 | "TRAEFIK_ENTRYPOINTS_websecure_ADDRESS=:443", 50 | "TRAEFIK_CERTIFICATESRESOLVERS_vesaresolver_ACME_STORAGE=acme.json", 51 | "TRAEFIK_CERTIFICATESRESOLVERS_vesaresolver_ACME_HTTPCHALLENGE=true", 52 | "TRAEFIK_CERTIFICATESRESOLVERS_vesaresolver_ACME_HTTPCHALLENGE_ENTRYPOINT=web", 53 | "TRAEFIK_CERTIFICATESRESOLVERS_vesaresolver_ACME_EMAIL={{.UserEmail}}" 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /web/.eslintrc.cjs: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | "parser": "@typescript-eslint/parser", 3 | "parserOptions": { 4 | "project": "tsconfig.json", 5 | "tsconfigRootDir": __dirname, 6 | }, 7 | "plugins": [ 8 | "@typescript-eslint" 9 | ], 10 | "extends": [ 11 | "next/core-web-vitals", 12 | "plugin:@typescript-eslint/recommended" 13 | ], 14 | "rules": { 15 | "quotes": [ 16 | "warn", 17 | "single" 18 | ], 19 | "comma-dangle": [ 20 | "warn", 21 | "always-multiline" 22 | ], 23 | "semi": [ 24 | "warn", 25 | "always" 26 | ], 27 | "@typescript-eslint/no-non-null-assertion": [ 28 | "off" 29 | ], 30 | "indent": [ 31 | "warn", 32 | 2 33 | ], 34 | "react-hooks/exhaustive-deps": "off", 35 | "@typescript-eslint/no-empty-function": "off", 36 | "@typescript-eslint/no-explicit-any": "off" 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /web/.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | pnpm-debug.log* 8 | lerna-debug.log* 9 | 10 | node_modules 11 | dist 12 | dist-ssr 13 | *.local 14 | 15 | # Editor directories and files 16 | .vscode/* 17 | !.vscode/extensions.json 18 | .idea 19 | .DS_Store 20 | *.suo 21 | *.ntvs* 22 | *.njsproj 23 | *.sln 24 | *.sw? 25 | -------------------------------------------------------------------------------- /web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | VESA 8 | 9 | 10 |
11 | 12 | 13 | 14 | -------------------------------------------------------------------------------- /web/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "web", 3 | "private": true, 4 | "version": "0.0.0", 5 | "type": "module", 6 | "scripts": { 7 | "dev": "vite", 8 | "build": "tsc && vite build", 9 | "preview": "vite preview" 10 | }, 11 | "dependencies": { 12 | "@chakra-ui/icons": "^2.1.1", 13 | "@chakra-ui/react": "^2.10.3", 14 | "@emotion/react": "^11.10.5", 15 | "@emotion/styled": "^11.10.5", 16 | "@hookform/resolvers": "^2.9.10", 17 | "@tanstack/react-query": "^4.22.0", 18 | "cron-parser": "^4.9.0", 19 | "dayjs": "^1.11.7", 20 | "framer-motion": "^6.5.1", 21 | "localforage": "^1.10.0", 22 | "match-sorter": "^6.3.1", 23 | "react": "^18.2.0", 24 | "react-dom": "^18.2.0", 25 | "react-hook-form": "^7.41.5", 26 | "react-router-dom": "^6.6.2", 27 | "sort-by": "^1.2.0", 28 | "zod": "^3.20.2" 29 | }, 30 | "devDependencies": { 31 | "@types/react": "^18.0.26", 32 | "@types/react-dom": "^18.0.9", 33 | "@typescript-eslint/eslint-plugin": "^5.33.0", 34 | "@typescript-eslint/parser": "^5.33.0", 35 | "@vitejs/plugin-react": "^3.0.0", 36 | "eslint": "8.22.0", 37 | "eslint-config-next": "12.3.1", 38 | "typescript": "^5.6.2", 39 | "vite": "^4.0.0" 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /web/public/vite.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /web/src/App.tsx: -------------------------------------------------------------------------------- 1 | import { createBrowserRouter, redirect, RouterProvider } from 'react-router-dom'; 2 | import Layout from './components/Layout'; 3 | import Login from './components/Login'; 4 | import NetworkDetails from './features/networks/NetworkDetails'; 5 | import ContainerDetails from './features/containers/ContainerDetails'; 6 | import Projects from './features/projects/Projects'; 7 | import Containers from './features/containers/Containers'; 8 | import NewContainer from './features/containers/NewContainer'; 9 | import Networks from './features/networks/Networks'; 10 | import NewNetwork from './features/networks/NewNetwork'; 11 | import NewProject from './features/projects/NewProject'; 12 | import ProjectDetails from './features/projects/ProjectDetails'; 13 | import Templates from './features/templates/Templates'; 14 | import TemplateDetails from './features/templates/TemplateDetails'; 15 | import NewTemplate from './features/templates/NewTemplate'; 16 | import Settings from './features/settings/Settings'; 17 | import { Box } from '@chakra-ui/react'; 18 | import Apps from './features/apps/Apps'; 19 | import AppDetails from './features/apps/AppDetails'; 20 | import NewApp from './features/apps/NewApp'; 21 | import Jobs from './features/jobs/Jobs'; 22 | import NewJob from './features/jobs/NewJob'; 23 | import JobDetails from './features/jobs/JobDetails'; 24 | 25 | const router = createBrowserRouter([ 26 | { 27 | path: '/', 28 | element: , 29 | children: [ 30 | { 31 | path: '/login', 32 | element: , 33 | }, 34 | { 35 | path: '/settings', 36 | element: , 37 | }, 38 | { 39 | path: '/containers', 40 | element: , 41 | }, 42 | { 43 | path: '/containers/new', 44 | element: , 45 | }, 46 | { 47 | path: '/containers/:id', 48 | element: , 49 | }, 50 | { 51 | path: '/networks', 52 | element: , 53 | }, 54 | { 55 | path: '/networks/new', 56 | element: , 57 | }, 58 | { 59 | path: '/networks/:id', 60 | element: , 61 | }, 62 | { 63 | path: '/projects', 64 | element: , 65 | }, 66 | { 67 | path: '/projects/new', 68 | element: , 69 | }, 70 | { 71 | path: '/projects/:id', 72 | element: , 73 | }, 74 | { 75 | path: '/projects/:id/edit', 76 | element: , 77 | }, 78 | { 79 | path: '/templates', 80 | element: , 81 | }, 82 | { 83 | path: '/templates/new', 84 | element: , 85 | }, 86 | { 87 | path: '/templates/:id', 88 | element: , 89 | }, 90 | { 91 | path: '/templates/:id/edit', 92 | element: , 93 | }, 94 | { 95 | path: '/apps', 96 | element: , 97 | }, 98 | { 99 | path: '/apps/:id', 100 | element: , 101 | }, 102 | { 103 | path: '/apps/new', 104 | element: , 105 | }, 106 | { 107 | path: '/apps/:id/edit', 108 | element: , 109 | }, 110 | { 111 | path: '/jobs', 112 | element: , 113 | }, 114 | { 115 | path: '/jobs/:id', 116 | element: , 117 | }, 118 | { 119 | path: '/jobs/new', 120 | element: , 121 | }, 122 | { 123 | path: '/jobs/:id/edit', 124 | element: , 125 | }, 126 | 127 | { 128 | path: '/', 129 | index: true, 130 | loader: () => redirect('/containers'), 131 | }, 132 | ], 133 | }, 134 | ]); 135 | 136 | function App() { 137 | return ( 138 | 139 | 140 | 141 | ); 142 | } 143 | 144 | export default App; 145 | -------------------------------------------------------------------------------- /web/src/api/api.ts: -------------------------------------------------------------------------------- 1 | import { authHeaders } from './auth.api'; 2 | 3 | export const apiUrl = window.location.host.endsWith(':5173') ? 'http://localhost:8989' : ''; 4 | 5 | export type RequestOptions = RequestInit; 6 | 7 | export class ApiError extends Error { 8 | constructor(message: string, description?: string) { 9 | super(message); 10 | 11 | this.description = description; 12 | } 13 | 14 | description?: string; 15 | } 16 | 17 | export const buildQuery = (data?: { [key in string]: string | undefined | null }) => { 18 | if (!data) { 19 | return ''; 20 | } 21 | 22 | const params = new URLSearchParams(); 23 | for (const key in data) { 24 | if (data[key] !== undefined && data[key] !== null) { 25 | params.append(key, encodeURIComponent(data[key] as string)); 26 | } 27 | } 28 | const paramsStr = params.toString(); 29 | 30 | return paramsStr ? `?${paramsStr}` : ''; 31 | }; 32 | 33 | export const authRequest = async (url: string, init?: RequestOptions) => { 34 | const headers = await authHeaders(); 35 | const response = await fetch(`${apiUrl}${url}`, { 36 | ...(init || {}), 37 | headers: { 38 | ...headers, 39 | ...(init?.headers || {}), 40 | }, 41 | }); 42 | 43 | if (!response.ok) { 44 | if (response.status === 401) { 45 | window.location.href = '/login'; 46 | } 47 | 48 | if (response.status === 400) { 49 | const error = await response.json(); 50 | 51 | if (error.type === 'validation') { 52 | let desc = ''; 53 | for (const key in error.errors) { 54 | desc += `${key}: ${error.errors[key]}\n`; 55 | } 56 | throw new ApiError(error.message, desc); 57 | } 58 | } 59 | 60 | const body = await response.text(); 61 | throw new Error('Request failed: ' + body || 'Request failed'); 62 | } 63 | 64 | return response; 65 | }; 66 | -------------------------------------------------------------------------------- /web/src/api/auth.api.ts: -------------------------------------------------------------------------------- 1 | import dayjs from 'dayjs'; 2 | import { LoginData, LoginRequest } from '../types'; 3 | import { apiUrl } from './api'; 4 | 5 | 6 | export const login = async (data: LoginRequest): Promise => { 7 | const form = new FormData(); 8 | form.append('username', data.user); 9 | form.append('password', data.passwd); 10 | form.append('grant_type', 'password'); 11 | 12 | const response = await fetch(`${apiUrl}/api/token`, { 13 | method: 'POST', 14 | body: form, 15 | }); 16 | 17 | if (response.ok) { 18 | return response.json(); 19 | } else { 20 | throw new Error(response.statusText); 21 | } 22 | }; 23 | 24 | export const refreshToken = async (refreshToken: string): Promise => { 25 | const form = new FormData(); 26 | form.append('refresh_token', refreshToken); 27 | form.append('grant_type', 'refresh_token'); 28 | 29 | const response = await fetch(`${apiUrl}/api/token`, { 30 | method: 'POST', 31 | body: form, 32 | }); 33 | 34 | if (response.ok) { 35 | return response.json(); 36 | } else { 37 | window.location.href = '/login'; 38 | throw new Error(response.statusText); 39 | } 40 | }; 41 | 42 | export const authHeaders = async (): Promise<{ Authorization?: string }> => { 43 | const data: LoginData = JSON.parse(localStorage.getItem('auth') || '{}'); 44 | if (!data.access_token) { 45 | return {}; 46 | } 47 | 48 | if (dayjs(data.expires_at).isBefore(dayjs()) && data.refresh_token) { 49 | // const newData = await refreshToken(data.refresh_token); 50 | 51 | // localStorage.setItem('auth', JSON.stringify({ 52 | // ...newData, 53 | // expires_at: dayjs().add(newData.expires_in, 'second').toISOString(), 54 | // })); 55 | 56 | // data = newData; 57 | } 58 | 59 | return { Authorization: `Bearer ${data.access_token}` }; 60 | }; 61 | -------------------------------------------------------------------------------- /web/src/components/FieldValue.tsx: -------------------------------------------------------------------------------- 1 | import { ViewIcon, ViewOffIcon } from '@chakra-ui/icons'; 2 | import { Text, Flex, VStack, Link, IconButton } from '@chakra-ui/react'; 3 | import { useState } from 'react'; 4 | import { Link as RouterLink } from 'react-router-dom'; 5 | 6 | export type FieldValueProps = { 7 | label: string; 8 | value?: Value; 9 | hidden?: boolean; 10 | }; 11 | 12 | export type LinkedValue = { 13 | label: string; 14 | link: string; 15 | external?: boolean 16 | } 17 | 18 | export type FieldValuesProps = { 19 | hidden?: boolean; 20 | label: string; 21 | values?: string[] | number[] | boolean[] | LinkedValue[] | null; 22 | } 23 | 24 | type Value = string | number | boolean | LinkedValue | null; 25 | 26 | const FieldValue: React.FC = ({ hidden, label, value }) => { 27 | const [isHidden, setIsHidden] = useState(hidden || false); 28 | if (value === true || value === false) { 29 | value = value ? 'true' : 'false'; 30 | } 31 | 32 | if (!value) { 33 | return null; 34 | } 35 | 36 | return ( 37 | 38 | 39 | {label} 40 | {hidden && ( 41 | : } 43 | onClick={() => setIsHidden(x => !x)} 44 | aria-label="Hide/Show" 45 | size="sm" 46 | variant="ghost" 47 | mr={2} 48 | /> 49 | )} 50 | 51 | 53 | ); 54 | }; 55 | 56 | export const FieldValues: React.FC = ({ hidden, label, values }) => { 57 | const [isHidden, setIsHidden] = useState(hidden || false); 58 | if (!values?.length) { 59 | return null; 60 | } 61 | 62 | const displayValues = isHidden ? ['******'] : values; 63 | 64 | return ( 65 | 66 | 67 | {label} 68 | {hidden && ( 69 | : } 71 | onClick={() => setIsHidden(x => !x)} 72 | aria-label="Hide/Show" 73 | size="sm" 74 | variant="ghost" 75 | mr={2} 76 | /> 77 | )} 78 | 79 | 80 | {displayValues.map((value, i) => ( 81 | 84 | 85 | ); 86 | }; 87 | 88 | const Value: React.FC<{ value?: Value; hidden: boolean }> = ({ value, hidden }) => { 89 | if (value === null) { 90 | return null; 91 | } 92 | 93 | if (value === true || value === false) { 94 | value = value ? 'true' : 'false'; 95 | } 96 | 97 | if (hidden) { 98 | value = '******'; 99 | } 100 | 101 | if (typeof value === 'object') { 102 | return value.external 103 | ? ( 104 | 110 | {value.label} 111 | 112 | ) : ( 113 | 118 | {value.label} 119 | 120 | ); 121 | } 122 | 123 | return ( 124 | {value} 125 | ); 126 | }; 127 | 128 | export default FieldValue; 129 | -------------------------------------------------------------------------------- /web/src/components/Layout.tsx: -------------------------------------------------------------------------------- 1 | import { Container, Flex } from '@chakra-ui/react'; 2 | import { PropsWithChildren } from 'react'; 3 | import { Outlet } from 'react-router-dom'; 4 | import Navbar from './Navbar'; 5 | 6 | const Layout: React.FC = () => { 7 | return ( 8 | 9 | 10 | 11 | 12 | 13 | 14 | ); 15 | }; 16 | 17 | export default Layout; 18 | -------------------------------------------------------------------------------- /web/src/components/Login.tsx: -------------------------------------------------------------------------------- 1 | import { zodResolver } from '@hookform/resolvers/zod'; 2 | import dayjs from 'dayjs'; 3 | import { useForm } from 'react-hook-form'; 4 | import { useNavigate } from 'react-router-dom'; 5 | import { login } from '../api/auth.api'; 6 | import { useDefaultMutation } from '../hooks'; 7 | import { LoginRequest, loginSchema } from '../types'; 8 | import FormContainer from './form/formContainer'; 9 | import FormInput from './form/formInput'; 10 | 11 | const Login: React.FC = () => { 12 | const { register, handleSubmit, formState: { errors } } = useForm({ 13 | resolver: zodResolver(loginSchema), 14 | }); 15 | 16 | const navigate = useNavigate(); 17 | const { mutate } = useDefaultMutation(login, { 18 | action: 'logging in', 19 | onSuccess: (data) => { 20 | localStorage.setItem('auth', JSON.stringify({ 21 | ...data, 22 | expires_at: dayjs().add(data.expires_in, 'seconds').toISOString(), 23 | })); 24 | 25 | navigate('/'); 26 | }, 27 | }); 28 | 29 | return ( 30 | mutate(data))} 34 | > 35 | 41 | 48 | 49 | ); 50 | }; 51 | 52 | export default Login; -------------------------------------------------------------------------------- /web/src/components/Navbar.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Flex } from '@chakra-ui/react'; 2 | import { PropsWithChildren } from 'react'; 3 | import { Link } from 'react-router-dom'; 4 | 5 | const Navbar: React.FC = () => { 6 | return ( 7 | 8 | VESA 9 | 10 | Containers 11 | Networks 12 | {/* Projects */} 13 | Templates 14 | Apps 15 | Jobs 16 | Settings 17 | 18 | 19 | ); 20 | }; 21 | 22 | type NavLinkProps = PropsWithChildren & { 23 | href?: string; 24 | onClick?: () => void; 25 | } 26 | 27 | const NavLink: React.FC = ({ href, onClick, children }) => { 28 | if (href) { 29 | return ( 30 | 31 | 32 | 33 | ); 34 | } 35 | if (onClick) { 36 | return ( 37 | 38 | ); 39 | } 40 | 41 | return null; 42 | }; 43 | 44 | export default Navbar; 45 | -------------------------------------------------------------------------------- /web/src/components/form/formContainer.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Flex, Heading } from '@chakra-ui/react'; 2 | import { FormEventHandler } from 'react'; 3 | 4 | export type FormContainerProps = { 5 | children: React.ReactNode; 6 | label: React.ReactNode; 7 | buttonLabel: string; 8 | onSubmit?: FormEventHandler | undefined; 9 | isLoading?: boolean; 10 | }; 11 | 12 | const FormContainer: React.FC = ({ 13 | children, 14 | label, 15 | buttonLabel, 16 | onSubmit, 17 | isLoading, 18 | }) => { 19 | return ( 20 | 27 | {label} 28 | 29 | {children} 30 | 31 | 34 | 35 | ); 36 | }; 37 | 38 | export default FormContainer; 39 | -------------------------------------------------------------------------------- /web/src/components/form/formFieldWrapper.tsx: -------------------------------------------------------------------------------- 1 | import { FormControl, FormControlProps, FormErrorMessage, FormHelperText, FormLabel } from '@chakra-ui/react'; 2 | import { PropsWithChildren } from 'react'; 3 | 4 | export type FormFieldWrapperProps = { 5 | label?: string; 6 | name: string; 7 | errorField?: string; 8 | containerProps?: FormControlProps; 9 | errors?: { 10 | [key: string]: { 11 | message?: string; 12 | type?: string; 13 | } 14 | }; 15 | helperText?: string; 16 | alignLabel?: 'right' | 'left' | 'inherit' | '-moz-initial' | 'initial' | 'revert' | 'unset' | 'center' | 'end' | 'justify' | 'match-parent' | 'start'; 17 | required?: boolean; 18 | } 19 | 20 | const errorMessages: Record = { 21 | 'required': 'This field is required', 22 | }; 23 | 24 | const getNestedError = (name: string, errors: any): any => { 25 | return name.split('.').reduce((acc, part) => acc && acc[part], errors); 26 | }; 27 | 28 | const FormFieldWrapper: React.FC> = ({ 29 | label, 30 | containerProps = {}, 31 | errors, 32 | name, 33 | errorField, 34 | helperText, 35 | alignLabel, 36 | children, 37 | required, 38 | }) => { 39 | const isInvalid = Boolean(name && errors && getNestedError(errorField || name, errors)); 40 | const error = getNestedError(errorField || name, errors); 41 | const errorMsg = error?.message || errorMessages[error?.type ?? '']; 42 | 43 | return ( 44 | 45 | {label && {label}} 46 | {children} 47 | {isInvalid && {errorMsg}} 48 | {helperText && {helperText}} 49 | 50 | ); 51 | }; 52 | 53 | export default FormFieldWrapper; 54 | -------------------------------------------------------------------------------- /web/src/components/form/formInput.tsx: -------------------------------------------------------------------------------- 1 | import { Input, InputProps, forwardRef } from '@chakra-ui/react'; 2 | import FormFieldWrapper, { FormFieldWrapperProps } from './formFieldWrapper'; 3 | 4 | export type FormInputProps = InputProps & FormFieldWrapperProps; 5 | 6 | const FormInput = forwardRef(({ 7 | label, 8 | containerProps = {}, 9 | errors, 10 | name, 11 | errorField, 12 | helperText, 13 | alignLabel, 14 | ...inputProps 15 | }, ref) => { 16 | 17 | return ( 18 | 28 | 29 | 30 | ); 31 | }); 32 | 33 | export default FormInput; 34 | -------------------------------------------------------------------------------- /web/src/components/form/formSelect.tsx: -------------------------------------------------------------------------------- 1 | import { Select, SelectProps, forwardRef } from '@chakra-ui/react'; 2 | import FormFieldWrapper, { FormFieldWrapperProps } from './formFieldWrapper'; 3 | 4 | export type NamedValue = { 5 | name: string; 6 | value: TVal; 7 | } 8 | 9 | export type FormSelectProps = SelectProps & FormFieldWrapperProps & { 10 | data: NamedValue[]; 11 | } 12 | 13 | const FormSelect = forwardRef(({ 14 | label, 15 | containerProps = {}, 16 | errors, 17 | name, 18 | helperText, 19 | alignLabel, 20 | data, 21 | errorField, 22 | ...inputProps 23 | }, ref) => { 24 | return ( 25 | 34 | 45 | 46 | ); 47 | }); 48 | 49 | export default FormSelect; 50 | -------------------------------------------------------------------------------- /web/src/components/form/formTextarea.tsx: -------------------------------------------------------------------------------- 1 | import { Textarea, TextareaProps, forwardRef } from '@chakra-ui/react'; 2 | import FormFieldWrapper, { FormFieldWrapperProps } from './formFieldWrapper'; 3 | 4 | export type FormTextareaProps = TextareaProps & FormFieldWrapperProps; 5 | 6 | const FormTextarea = forwardRef(({ 7 | label, 8 | containerProps = {}, 9 | errors, 10 | name, 11 | helperText, 12 | alignLabel, 13 | ...inputProps 14 | }, ref) => { 15 | return ( 16 | 24 |