diff --git a/CLAUDE.md b/CLAUDE.md index b2be3d8..61cbc18 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -75,7 +75,9 @@ Makefile Multi-arch build + S3 publish. 5. `StepReview` — shows all collected config; Enter confirms. 6. `StepGenerateFile` — writes `config.toml` (validates numeric fields first). 7. `StepRunDocker` — runs `app-dono-cliente` container. -8. `StepDone`. +8. `StepRunUpdater` — runs `app-dono-updater`, which auto-updates `app-dono-cliente` + whenever a new image is pushed to `:latest`. +9. `StepDone`. ### Navigation & error handling @@ -95,6 +97,7 @@ In [update.go](internal/tui/update.go): - `imageName = hub.davinti.com.br:443/app-dono/app-cliente:latest` - `wireguardImageName = hub.davinti.com.br:443/davinti-vproxy:latest` +- `updaterImageName = docker:cli` - `configPath = config.toml`, `wireguardConfigPath = envs` In [docker.go](internal/tui/docker.go): `networkName = app-dono_app` (all containers @@ -115,6 +118,7 @@ Both are gitignored. | ------------------ | -------------------- | -------------------------------------------------------- | | `app-dono-cliente` | app-cliente | `:8080`, mounts config.toml + cert dir, `--restart unless-stopped`, runs as host uid:gid. | | `vproxy` | davinti-vproxy | `--cap-add=NET_ADMIN`, `/dev/net/tun`, `--env-file envs`, only without public IP. | +| `app-dono-updater` | docker:cli | Not a third-party updater — a poll loop (`sh -c`) baked into the official `docker:cli` image, since Watchtower was archived upstream in Dec 2025 with no maintained successor recommended for production. Every `updaterPollIntervalSeconds` (300s) it `docker pull`s `app-dono-cliente`'s image, compares image IDs, and if changed, stops/removes/recreates the container using the exact same `docker run` argv as the original start (`appClienteRunArgs` in `docker.go`, shared by both call sites so they can't drift). Mounts `/var/run/docker.sock` and the host's `~/.docker/config.json` (written by the earlier `docker login`) for private-registry auth. Not on `app-dono_app` — only talks to the Docker daemon. | `seccomp=unconfined` is added to either container when the "Modo Compatibilidade" (`seccomp_unconfined`) select is `"Sim"` — for old machines. diff --git a/README.md b/README.md index 71c8974..cad294f 100644 --- a/README.md +++ b/README.md @@ -45,7 +45,9 @@ O instalador conduz o operador por um assistente (wizard) no terminal que: 5. Coleta, via formulários, as configurações de **aplicação, servidor, banco de dados e certificados**. 6. **Gera o `config.toml`** e sobe o container `app-dono-cliente`. -7. Exibe a confirmação de sucesso. +7. Sobe o container **`app-dono-updater`**, que mantém o `app-dono-cliente` + atualizado automaticamente a cada novo push em `:latest`. +8. Exibe a confirmação de sucesso. ## Pré-requisitos @@ -131,6 +133,11 @@ go run ./cmd ┌──────────────────┐ │ Sobe container │ (app-dono-cliente) │ app-dono-cliente │ + └────────┬─────────┘ + ▼ + ┌──────────────────┐ + │ Sobe container │ (app-dono-updater, + │ de auto-update │ atualiza o app-cliente sozinho) └────────┬─────────┘ ▼ ✅ Concluído @@ -161,20 +168,29 @@ o instalador (no caso do `config.toml`). ## Containers e rede Docker -Todos os containers são conectados à rede Docker **`app-dono_app`** (criada -automaticamente se não existir). +`app-dono-cliente` e `vproxy` são conectados à rede Docker **`app-dono_app`** (criada +automaticamente se não existir). O `app-dono-updater` fica fora dessa rede — ele só fala +com o daemon Docker via socket, não com os outros containers pela rede. -| Container | Imagem | Quando sobe | -| ------------------ | ------------------------------------------------- | -------------------- | -| `app-dono-cliente` | `hub.davinti.com.br:443/app-dono/app-cliente` | Sempre | -| `vproxy` | `hub.davinti.com.br:443/davinti-vproxy` | Quando não há IP púb.| +| Container | Imagem | Quando sobe | +| --------------------- | ------------------------------------------------ | -------------------- | +| `app-dono-cliente` | `hub.davinti.com.br:443/app-dono/app-cliente` | Sempre | +| `vproxy` | `hub.davinti.com.br:443/davinti-vproxy` | Quando não há IP púb.| +| `app-dono-updater` | `docker:cli` | Sempre | Características: -- Ambos sobem com `--restart unless-stopped`. +- Todos sobem com `--restart unless-stopped`. - O container do app expõe a porta configurada no host, mapeando para a `8080` interna, e monta o `config.toml` e o diretório de certificados como volumes. - O `vproxy` roda com `--cap-add=NET_ADMIN` e acesso a `/dev/net/tun`. +- O `app-dono-updater` **não** é o Watchtower — esse projeto foi arquivado pelos + mantenedores originais em dez/2025 sem um sucessor mantido recomendado para produção. + Em vez disso, é um loop simples (`sh -c`) rodando na imagem oficial `docker:cli`: a + cada 5 minutos baixa a imagem do `app-dono-cliente`, compara com a que está rodando e, + se mudou, recria o container. Usa as mesmas credenciais do login feito no passo 2 (via + `~/.docker/config.json`) e precisa de acesso ao socket do Docker + (`/var/run/docker.sock`) para poder recriar o container. - O **modo compatibilidade** (`seccomp=unconfined`) pode ser ativado para máquinas antigas onde o seccomp padrão causa problemas. diff --git a/internal/tui/cmds.go b/internal/tui/cmds.go index 7aa98c1..8d49694 100644 --- a/internal/tui/cmds.go +++ b/internal/tui/cmds.go @@ -114,3 +114,13 @@ func RunWireguardContainer(path string, cv ConfigValues) tea.Cmd { } } } + +func RunUpdaterContainer(appImage, appContainerName, configPath, configDestinationPath string, cv ConfigValues) tea.Cmd { + return func() tea.Msg { + err := RunUpdaterDockerContainer(appImage, appContainerName, configPath, configDestinationPath, cv) + + return DockerRunMsg{ + Err: err, + } + } +} diff --git a/internal/tui/docker.go b/internal/tui/docker.go index b81142c..6aad510 100644 --- a/internal/tui/docker.go +++ b/internal/tui/docker.go @@ -2,6 +2,7 @@ package tui import ( "fmt" + "os" "os/exec" "os/user" "path/filepath" @@ -106,21 +107,18 @@ func RunWireguardDockerContainer(envFilePath string, cv ConfigValues) error { return verifyContainerRunning(containerID) } -func RunAppClienteContainer(image, containerName, configPath, configDestinationPath string, cv ConfigValues) error { - removeExistingContainer(containerName) - - if err := EnsureNetwork(networkName); err != nil { - return err - } - +// appClienteRunArgs builds the `docker run` argv for the app-dono-cliente container. +// Shared by RunAppClienteContainer (initial start) and RunUpdaterDockerContainer (which +// re-embeds the same argv in its recreate script), so the two never drift apart. +func appClienteRunArgs(image, containerName, configPath, configDestinationPath string, cv ConfigValues) ([]string, error) { absPath, err := filepath.Abs(configPath) if err != nil { - return fmt.Errorf("erro ao resolver caminho absoluto: %w", err) + return nil, fmt.Errorf("erro ao resolver caminho absoluto: %w", err) } currentUser, err := user.Current() if err != nil { - return err + return nil, err } uidGid := fmt.Sprintf("%s:%s", currentUser.Uid, currentUser.Gid) @@ -138,6 +136,111 @@ func RunAppClienteContainer(image, containerName, configPath, configDestinationP args = append(args, "--security-opt", "seccomp=unconfined") } args = append(args, image) + return args, nil +} + +func RunAppClienteContainer(image, containerName, configPath, configDestinationPath string, cv ConfigValues) error { + removeExistingContainer(containerName) + + if err := EnsureNetwork(networkName); err != nil { + return err + } + + args, err := appClienteRunArgs(image, containerName, configPath, configDestinationPath, cv) + if err != nil { + return err + } + cmd := exec.Command("docker", args...) + + out, err := cmd.CombinedOutput() + if err != nil { + return fmt.Errorf("docker run falhou: %w\noutput: %s", err, string(out)) + } + + time.Sleep(2 * time.Second) + containerID := strings.TrimSpace(string(out)) + return verifyContainerRunning(containerID) +} + +// shellQuote POSIX single-quotes s for safe embedding in the updater's poll script: +// wrap in '...', escaping any embedded ' as '\''. Needed because cv fields (e.g. +// cert_dir_path) are operator-entered and end up inside a shell script, not a plain +// argv slot. +func shellQuote(s string) string { + return "'" + strings.ReplaceAll(s, "'", `'\''`) + "'" +} + +func shellQuoteArgs(args []string) string { + quoted := make([]string, len(args)) + for i, a := range args { + quoted[i] = shellQuote(a) + } + return strings.Join(quoted, " ") +} + +// updaterPollIntervalSeconds is how often the updater checks the registry for a new +// app-cliente image. +const updaterPollIntervalSeconds = 300 + +// RunUpdaterDockerContainer starts a tiny self-contained auto-updater for the +// app-dono-cliente container: no third-party updater project, just the official +// `docker:cli` image running a poll loop (docker pull, compare image IDs, recreate on +// change) written in Go and handed to it via `sh -c`. This exists because Watchtower +// (the previous approach) was archived upstream with no maintained drop-in successor +// recommended for production use — see StepRunUpdater in update.go. +// +// It mounts the docker socket (to pull/recreate) and the host's docker config.json +// (written by the StepDockerLogin `docker login`) so `docker pull` can authenticate +// against the private registry. +func RunUpdaterDockerContainer(appImage, appContainerName, configPath, configDestinationPath string, cv ConfigValues) error { + updaterName := "app-dono-updater" + + removeExistingContainer(updaterName) + + if out, err := PullImage(updaterImageName); err != nil { + return fmt.Errorf("erro ao baixar imagem do atualizador: %w\noutput: %s", err, out) + } + + home, err := os.UserHomeDir() + if err != nil { + return fmt.Errorf("erro ao localizar diretório home: %w", err) + } + dockerConfigPath := filepath.Join(home, ".docker", "config.json") + + recreateArgs, err := appClienteRunArgs(appImage, appContainerName, configPath, configDestinationPath, cv) + if err != nil { + return err + } + recreateCmd := "docker " + shellQuoteArgs(recreateArgs) + + script := fmt.Sprintf(`set -e +IMAGE=%s +NAME=%s +while true; do + docker pull "$IMAGE" >/dev/null 2>&1 || true + CURRENT=$(docker inspect --format '{{.Image}}' "$NAME" 2>/dev/null || true) + LATEST=$(docker inspect --format '{{.Id}}' "$IMAGE" 2>/dev/null || true) + if [ -n "$LATEST" ] && [ "$CURRENT" != "$LATEST" ]; then + docker stop "$NAME" >/dev/null 2>&1 || true + docker rm "$NAME" >/dev/null 2>&1 || true + %s + fi + sleep %d +done +`, shellQuote(appImage), shellQuote(appContainerName), recreateCmd, updaterPollIntervalSeconds) + + args := []string{ + "run", "-d", + "--name", updaterName, + "--restart", "unless-stopped", + "-v", "/var/run/docker.sock:/var/run/docker.sock", + "-v", fmt.Sprintf("%s:/config.json", dockerConfigPath), + "--log-opt", "max-size=5m", + "--log-opt", "max-file=1", + "--entrypoint", "sh", + updaterImageName, + "-c", script, + } cmd := exec.Command("docker", args...) out, err := cmd.CombinedOutput() diff --git a/internal/tui/docker_test.go b/internal/tui/docker_test.go new file mode 100644 index 0000000..94f045d --- /dev/null +++ b/internal/tui/docker_test.go @@ -0,0 +1,41 @@ +package tui + +import ( + "os/exec" + "reflect" + "strings" + "testing" +) + +// TestShellQuoteArgsRoundTrip guards the updater's poll-script generation: cv fields +// (e.g. cert_dir_path) are operator-entered and get embedded into a shell script run +// inside the updater container. If shellQuote/shellQuoteArgs mis-escapes a value, that's +// a command-injection bug, not just a cosmetic one. This feeds tricky values through a +// real `sh` and checks they come back out exactly as they went in. +func TestShellQuoteArgsRoundTrip(t *testing.T) { + if _, err := exec.LookPath("sh"); err != nil { + t.Skip("sh not available") + } + + cases := [][]string{ + {"simple"}, + {"has space"}, + {"it's got a quote"}, + {"$(echo injected)"}, + {"a;b|c&d"}, + {"back`tick`"}, + {"multi", "arg space", "o'clock", "$HOME", "'"}, + } + + for _, args := range cases { + script := "printf '%s\\n' " + shellQuoteArgs(args) + out, err := exec.Command("sh", "-c", script).Output() + if err != nil { + t.Fatalf("sh failed for %v: %v", args, err) + } + got := strings.Split(strings.TrimRight(string(out), "\n"), "\n") + if !reflect.DeepEqual(got, args) { + t.Errorf("round trip mismatch for %v: got %v", args, got) + } + } +} diff --git a/internal/tui/steps.go b/internal/tui/steps.go index 8e1730a..34d719d 100644 --- a/internal/tui/steps.go +++ b/internal/tui/steps.go @@ -30,6 +30,7 @@ const ( // Finalizing StepGenerateFile StepRunDocker + StepRunUpdater StepDone ) diff --git a/internal/tui/update.go b/internal/tui/update.go index 709d8ef..6c4c9ec 100644 --- a/internal/tui/update.go +++ b/internal/tui/update.go @@ -11,6 +11,7 @@ import ( const ( imageName = "hub.davinti.com.br:443/app-dono/app-cliente:latest" wireguardImageName = "hub.davinti.com.br:443/davinti-vproxy:latest" + updaterImageName = "docker:cli" configPath = "config.toml" wireguardConfigPath = "envs" ) @@ -138,6 +139,8 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m.updateGenerateFile(msg) case StepRunDocker: return m.updateRunDocker(msg) + case StepRunUpdater: + return m.updateRunUpdater(msg) case StepDone: return m, tea.Quit } @@ -396,6 +399,49 @@ func (m Model) updateRunDocker(msg tea.Msg) (tea.Model, tea.Cmd) { default: return m, tea.Quit } + } else if m.finishedDockerRun && m.dockerRunError == nil { + m.currentStep = StepRunUpdater + + m.finishedDockerRun = false + m.dockerRunError = nil + + return m, RunUpdaterContainer( + imageName, + "app-dono-cliente", + configPath, + fmt.Sprintf("/app/%s", configPath), + m.configValues, + ) + } + } + + return m, nil +} + +// updateRunUpdater starts the auto-update agent that watches the app-dono-cliente +// container and pulls/recreates it whenever a new image is pushed to :latest. +func (m Model) updateRunUpdater(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case DockerRunMsg: + m.finishedDockerRun = true + m.dockerRunError = msg.Err + + case tea.KeyPressMsg: + if m.finishedDockerRun && m.dockerRunError != nil { + switch msg.String() { + case "r": + m.finishedDockerRun = false + m.dockerRunError = nil + return m, RunUpdaterContainer( + imageName, + "app-dono-cliente", + configPath, + fmt.Sprintf("/app/%s", configPath), + m.configValues, + ) + default: + return m, tea.Quit + } } else if m.finishedDockerRun && m.dockerRunError == nil { m.currentStep = StepDone } diff --git a/internal/tui/view.go b/internal/tui/view.go index 4351cf0..9965151 100644 --- a/internal/tui/view.go +++ b/internal/tui/view.go @@ -107,6 +107,11 @@ func (m Model) View() tea.View { if m.finishedDockerRun && m.dockerRunError != nil { helpMsg = retryMsg } + case StepRunUpdater: + body = m.viewDockerRun() + if m.finishedDockerRun && m.dockerRunError != nil { + helpMsg = retryMsg + } case StepDone: body = m.viewDoneMessage() helpMsg = anyKeyOutMsg