v0.331.0: OnlyOffice opens and saves from FileBrowser and Nextcloud; FileBrowser back at once after a restart (R-1018)
gates / gates (push) Successful in 48s

FileBrowser config carries server.internalUrl http://filebrowser:80 (OnlyOffice fetched the public files address and got 403);
Nextcloud's DocumentServerInternalUrl gets its trailing slash (every save was lost: http://onlyoffice:80cache/...);
RestartStack("filebrowser") runs the R-1126 gate at once (files.<domain> was 502 for up to 5 min after the link switched).
Each red-proofed. MinAgent 0.158.1.
This commit is contained in:
2026-10-18 21:05:04 +02:00
parent 615940de35
commit 772ef36f5f
10 changed files with 317 additions and 44 deletions
+2 -2
View File
@@ -3893,7 +3893,7 @@ CRUD methods in settings.go: `GetIntegrationState`, `SetIntegrationState`, `Remo
#### Built-in Handlers
**OnlyOffice → FileBrowser** (`onlyoffice_filebrowser.go`):
- Apply: Reads `JWT_SECRET` + `SUBDOMAIN` from OnlyOffice app.yaml (decrypted), strips any existing `integrations:` block from FileBrowser `config.yaml` via `removeIntegrationsSection()`, appends new block with `url` (public HTTPS), `internalUrl` (`http://onlyoffice:80`), `secret`, `viewOnly: false`. Atomic write (`.tmp` + rename). Restarts FileBrowser
- Apply: Reads `JWT_SECRET` + `SUBDOMAIN` from OnlyOffice app.yaml (decrypted), strips any existing `integrations:` block from FileBrowser `config.yaml` via `removeIntegrationsSection()`, appends new block with `url` (public HTTPS), `internalUrl` (`http://onlyoffice:80`), `secret`, `viewOnly: false`. Atomic write (`.tmp` + rename). Restarts FileBrowser — and `RestartStack("filebrowser")` runs the R-1126 gate at once, so files.<domain> is back within seconds (v0.331.0, R-1018). OnlyOffice fetches and saves the document at FileBrowser's `server.internalUrl` (`http://filebrowser:80`, rendered in every FileBrowser config since v0.331.0) — never through the public address
- Revoke: Strips `integrations:` block from config.yaml, restarts FileBrowser
**OnlyOffice → Nextcloud** (`onlyoffice_nextcloud.go`):
@@ -3901,7 +3901,7 @@ CRUD methods in settings.go: `GetIntegrationState`, `SetIntegrationState`, `Remo
1. `app:install onlyoffice` (tolerates "already installed")
2. `app:enable onlyoffice`
3. `config:app:set onlyoffice DocumentServerUrl --value=https://{subdomain}.{domain}`
4. `config:app:set onlyoffice DocumentServerInternalUrl --value=http://onlyoffice:80`
4. `config:app:set onlyoffice DocumentServerInternalUrl --value=http://onlyoffice:80/` (the trailing slash is load-bearing: the connector appends paths — without it every save failed, v0.331.0, R-1018)
5. `config:app:set onlyoffice jwt_secret --value={JWT_SECRET}`
6. `config:app:set onlyoffice StorageUrl --value=http://nextcloud` (internal callback URL)
- Revoke: Runs `occ app:disable onlyoffice` (tolerates container not running / app not enabled)
+5
View File
@@ -459,6 +459,11 @@ func RenderFileBrowserConfigKept(paths []settings.StoragePath, importSource bool
server:
port: 80
baseURL: "/"
# R-1018: the address OnlyOffice's server uses to fetch and save a document (FileBrowser's onlyOffice.go builds both
# URLs from it at gtstef/filebrowser:1.5.6-stable; it is used by integrations only). Without it FileBrowser hands
# OnlyOffice https://files.<domain> — a trip out through the tunnel and back, which failed (403) on demo-hp.
# Pinned by TestFileBrowserConfig_R1018_InternalURLIsTheContainer.
internalUrl: "http://filebrowser:80"
# The box updates FileBrowser (the pinned image); a household cannot, so its „An update is available!" is noise.
# backend/common/settings/structs.go:51 at gtstef/filebrowser:1.5.6-stable (v0.321.0).
disableUpdateCheck: true
+29
View File
@@ -1,6 +1,8 @@
package infra
import (
"net/url"
"strconv"
"strings"
"testing"
@@ -248,6 +250,33 @@ func TestFileBrowserRender(t *testing.T) {
}
}
// R-1018: OnlyOffice's server fetches and saves a FileBrowser document at server.internalUrl. The consequence pinned:
// that URL names the FileBrowser container on port 80 — the name OnlyOffice resolves on traefik-public — and the
// container really is called that in the compose. RED-PROOF: drop the config line (FileBrowser then hands OnlyOffice
// the public https://files.<domain>, 403 on demo-hp 2026-10-18) or rename the container → this fails.
func TestFileBrowserConfig_R1018_InternalURLIsTheContainer(t *testing.T) {
var cfg struct {
Server struct {
InternalURL string `yaml:"internalUrl"`
Port int `yaml:"port"`
} `yaml:"server"`
}
if err := yaml.Unmarshal([]byte(RenderFileBrowserConfig([]settings.StoragePath{{Path: "/mnt/hdd_1"}}, true)), &cfg); err != nil {
t.Fatalf("config does not parse: %v", err)
}
u, err := url.Parse(cfg.Server.InternalURL)
if err != nil || u.Scheme != "http" || u.Hostname() == "" {
t.Fatalf("server.internalUrl = %q — OnlyOffice would be handed the public files address", cfg.Server.InternalURL)
}
if u.Port() != strconv.Itoa(cfg.Server.Port) {
t.Errorf("server.internalUrl port %q, FileBrowser listens on %d", u.Port(), cfg.Server.Port)
}
compose := RenderFileBrowserCompose("demo-felhom.eu", nil)
if !strings.Contains(compose, "container_name: "+u.Hostname()+"\n") {
t.Errorf("server.internalUrl names %q, which is not the FileBrowser container:\n%s", u.Hostname(), compose)
}
}
// R-1126 (D11 = A): FileBrowser starts OFF traefik-public — the box joins it there only after this run's default login
// is checked — and on its own network, which the controller joins for that check. v0.327.1: NO Traefik labels — the
// route is the file-provider route by NAME (a label route kept the address Traefik chose at container start, on the
@@ -29,41 +29,7 @@ func (h *OnlyOfficeNextcloudHandler) Apply(ac *ApplyContext) error {
}
publicURL := fmt.Sprintf("https://%s.%s", subdomain, ac.Domain)
internalURL := "http://onlyoffice:80"
// Install and configure OnlyOffice app in Nextcloud
commands := []struct {
args []string
tolerate string // substring in output to tolerate as success
}{
{
args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "app:install", "onlyoffice"},
tolerate: "already installed",
},
{
args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "app:enable", "onlyoffice"},
},
{
args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "config:app:set", "onlyoffice", "DocumentServerUrl", "--value=" + publicURL},
},
{
args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "config:app:set", "onlyoffice", "DocumentServerInternalUrl", "--value=" + internalURL},
},
{
args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "config:app:set", "onlyoffice", "jwt_secret", "--value=" + jwtSecret},
},
{
// StorageUrl: tells OO Document Server where to reach Nextcloud internally for file callbacks.
// Trailing slash is critical — NC's OO connector does string replacement of the server URL
// (which ends with /) with StorageUrl. Without trailing slash, "/apps/..." merges into hostname.
args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "config:app:set", "onlyoffice", "StorageUrl", "--value=http://nextcloud/"},
},
{
// Add internal hostname to trusted_domains so OO Document Server callbacks
// (arriving with Host: nextcloud) are not rejected by Nextcloud.
args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "config:system:set", "trusted_domains", "10", "--value=nextcloud"},
},
}
commands := nextcloudApplyCommands(publicURL, jwtSecret)
for _, cmd := range commands {
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
@@ -107,3 +73,52 @@ func (h *OnlyOfficeNextcloudHandler) Revoke(ac *ApplyContext) error {
ac.Logger.Printf("[INFO] [integrations] OnlyOffice integration revoked from Nextcloud")
return nil
}
// occCommand is one occ call of the apply; tolerate is a substring of its output that counts as success.
type occCommand struct {
args []string
tolerate string
}
// The two addresses OnlyOffice and Nextcloud use to reach each other inside the box. BOTH end with "/": Nextcloud's
// OnlyOffice connector appends paths to them („converter", „cache/files/…", „apps/onlyoffice/track"). R-1018,
// measured on demo-hp 2026-10-18: DocumentServerInternalUrl without the slash made Nextcloud fetch
// http://onlyoffice:80cache/files/… — every save failed („Track: … status 2 error") while the editor said
// „All changes saved". Pinned by TestNextcloudApply_R1018_InternalAddressesTakeAPath.
const (
ncDocServerInternalURL = "http://onlyoffice:80/"
ncStorageURL = "http://nextcloud/"
)
// nextcloudApplyCommands installs and configures the OnlyOffice app in Nextcloud.
func nextcloudApplyCommands(publicURL, jwtSecret string) []occCommand {
return []occCommand{
{
args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "app:install", "onlyoffice"},
tolerate: "already installed",
},
{
args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "app:enable", "onlyoffice"},
},
{
args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "config:app:set", "onlyoffice", "DocumentServerUrl", "--value=" + publicURL},
},
{
args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "config:app:set", "onlyoffice", "DocumentServerInternalUrl", "--value=" + ncDocServerInternalURL},
},
{
args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "config:app:set", "onlyoffice", "jwt_secret", "--value=" + jwtSecret},
},
{
// StorageUrl: tells OO Document Server where to reach Nextcloud internally for file callbacks.
// Trailing slash is critical — NC's OO connector does string replacement of the server URL
// (which ends with /) with StorageUrl. Without trailing slash, "/apps/..." merges into hostname.
args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "config:app:set", "onlyoffice", "StorageUrl", "--value=" + ncStorageURL},
},
{
// Add internal hostname to trusted_domains so OO Document Server callbacks
// (arriving with Host: nextcloud) are not rejected by Nextcloud.
args: []string{"docker", "exec", "-u", "www-data", "nextcloud", "php", "occ", "config:system:set", "trusted_domains", "10", "--value=nextcloud"},
},
}
}
@@ -0,0 +1,44 @@
package integrations
import (
"net/url"
"strings"
"testing"
)
// R-1018: Nextcloud's OnlyOffice connector builds its server-to-server requests by APPENDING a path to the two inside
// addresses the apply sets. The consequence pinned: the address the apply really sends, plus such a path, is a request
// to the right container. Measured on demo-hp 2026-10-18: without the slash Nextcloud asked
// http://onlyoffice:80cache/files/… and every save was lost. RED-PROOF: drop the slash from ncDocServerInternalURL →
// the cache URL no longer parses (port "80cache") and this fails.
func TestNextcloudApply_R1018_InternalAddressesTakeAPath(t *testing.T) {
values := map[string]string{}
for _, c := range nextcloudApplyCommands("https://office.example.hu", "secret") {
if len(c.args) >= 11 && c.args[7] == "config:app:set" {
values[c.args[9]] = strings.TrimPrefix(c.args[10], "--value=")
}
}
for _, tc := range []struct {
key, path, host string
}{
{"DocumentServerInternalUrl", "cache/files/data/2153553391_5872/output.docx/output.docx", "onlyoffice:80"},
{"DocumentServerInternalUrl", "converter?shardkey=thumb_1", "onlyoffice:80"},
{"StorageUrl", "apps/onlyoffice/track?doc=x", "nextcloud"},
} {
base, ok := values[tc.key]
if !ok {
t.Fatalf("the apply does not set %s (set: %v)", tc.key, values)
}
u, err := url.Parse(base + tc.path)
if err != nil {
t.Errorf("%s %q + %q is not a URL: %v", tc.key, base, tc.path, err)
continue
}
if u.Host != tc.host || !strings.HasPrefix(u.Path, "/"+strings.SplitN(tc.path, "?", 2)[0][:4]) {
t.Errorf("%s %q + %q reaches host %q path %q, want host %q", tc.key, base, tc.path, u.Host, u.Path, tc.host)
}
}
if values["DocumentServerUrl"] != "https://office.example.hu" {
t.Errorf("DocumentServerUrl = %q, want the public office address", values["DocumentServerUrl"])
}
}
@@ -0,0 +1,132 @@
package stacks
import (
"context"
"fmt"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"gitea.dooplex.hu/admin/felhom-controller/internal/config"
)
// R-1018: the OnlyOffice link restarts FileBrowser (Apply and Revoke), and a re-created FileBrowser starts off
// traefik-public (R-1126). The consequence pinned: after RestartStack("filebrowser") the NEW run is checked and joined to
// traefik-public by itself, within seconds — not at the next base-stack round (files.<domain> answered 502 for 4 min 8 s
// on demo-hp, 2026-10-18). Another app's restart publishes nothing. RED-PROOF: remove the gate call from RestartStack →
// "not published again" (nothing connects until a round runs, and no round runs here).
func TestRestartStack_R1018_FileBrowserPublishedAgainAtOnce(t *testing.T) {
if runtime.GOOS != "linux" {
t.Skip("the stub compose binary is a shell script")
}
fb := &fakeFB{password: "admin"}
srv := httptest.NewServer(fb.handler())
defer srv.Close()
m, _, _ := fbManager(t, srv.URL)
root := t.TempDir()
cfg := &config.Config{}
cfg.Paths.StacksDir = root
m.cfg = cfg
m.stacks = map[string]*Stack{}
for _, name := range []string{"filebrowser", "bookstack"} {
dir := filepath.Join(root, name)
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "docker-compose.yml"), []byte("services:\n web:\n image: nginx:1.27\n"), 0o644); err != nil {
t.Fatal(err)
}
m.stacks[name] = &Stack{Name: name, ComposePath: filepath.Join(dir, "docker-compose.yml"), Deployed: true}
}
// compose: a stub that records the recreate; docker inspect: the start time moves after it (RestartStack's own check).
bin := t.TempDir()
marker := filepath.Join(t.TempDir(), "recreated")
script := fmt.Sprintf("#!/bin/sh\nfor a in \"$@\"; do [ \"$a\" = \"--force-recreate\" ] && touch %q; done\nexit 0\n", marker)
if err := os.WriteFile(filepath.Join(bin, "docker-compose"), []byte(script), 0o755); err != nil {
t.Fatal(err)
}
t.Setenv("PATH", bin+string(os.PathListSeparator)+os.Getenv("PATH"))
m.composeCmd = "docker-compose"
m.execFn = func(string, ...string) (string, error) { return "", nil }
recreated := func() bool { _, err := os.Stat(marker); return err == nil }
m.installedExecFn = func(_ context.Context, _ string, _ []string, name string, args ...string) (string, error) {
if name == "docker" && len(args) > 0 && args[0] == "compose" {
name, args = "docker-compose", args[1:]
}
started := "2026-10-18T18:00:00Z"
if recreated() {
started = "2026-10-18T18:32:14Z"
}
switch {
case name == "docker-compose" && len(args) > 0 && args[0] == "ps":
return `{"ID":"aaa111bbb222","Name":"web-1","Service":"web"}`, nil
case name == "docker" && len(args) > 0 && args[0] == "inspect" && strings.Contains(strings.Join(args, " "), "State.StartedAt"):
return "aaa111bbb222ccc333" + inspectSep + "started" + inspectSep + started + "\n", nil
case name == "docker" && len(args) > 0 && args[0] == "inspect":
return "aaa111bbb222" + inspectSep + "nginx:1.27" + inspectSep + "sha256:i\n", nil
case name == "docker" && len(args) > 1 && args[0] == "image":
return "sha256:i" + inspectSep + "nginx@sha256:d\n", nil
}
return "", nil
}
// The gate's view of FileBrowser: run 1 was checked and public; a recreate makes run 2, off traefik-public.
var mu sync.Mutex
nets := map[string]bool{"filebrowser@traefik-public": true, "felhom-controller@felhom-filebrowser": true}
connected := make(chan string, 8)
m.fbStarted = func() string {
if recreated() {
return "run-2"
}
return "run-1"
}
m.fbProbedRun = "run-1"
m.fbNetOnFn = func(c, n string) bool { mu.Lock(); defer mu.Unlock(); return nets[c+"@"+n] }
m.fbNetConnectFn = func(c, n string) error {
mu.Lock()
nets[c+"@"+n] = true
mu.Unlock()
connected <- c + "@" + n
return nil
}
// Another app's restart publishes nothing.
if err := m.RestartStack("bookstack"); err != nil {
t.Fatalf("bookstack restart: %v", err)
}
select {
case c := <-connected:
t.Fatalf("a bookstack restart touched FileBrowser's networks: %s", c)
case <-time.After(300 * time.Millisecond):
}
os.Remove(marker)
// FileBrowser's restart: the recreate drops it from traefik-public; the gate must put run 2 back by itself.
mu.Lock()
delete(nets, "filebrowser@traefik-public")
mu.Unlock()
if err := m.RestartStack("filebrowser"); err != nil {
t.Fatalf("filebrowser restart: %v", err)
}
deadline := time.After(10 * time.Second)
for {
select {
case c := <-connected:
if c == "filebrowser@traefik-public" {
if m.fbProbedRun != "run-2" {
t.Fatalf("published, but the checked run is %q, not the new run-2", m.fbProbedRun)
}
return
}
case <-deadline:
t.Fatal("FileBrowser was not published again after its restart — files.<domain> stays 502 until a base-stack round")
}
}
}
+9
View File
@@ -1501,6 +1501,15 @@ func (m *Manager) RestartStack(name string) error {
}
m.mu.Unlock()
// R-1018: a re-created FileBrowser starts off traefik-public (R-1126), so files.<domain> answered 502 until the next
// base-stack round — up to 5 minutes, measured 4 min 8 s on demo-hp 2026-10-18 after the OnlyOffice link switched
// on (its Apply and Revoke restart FileBrowser). Check and publish this run now, in the background like the mounts
// sync does (handlers.go): the check may wait ~20 s for FileBrowser to answer, and it takes infraMu, which no caller
// of RestartStack holds. Pinned by TestRestartStack_R1018_FileBrowserPublishedAgainAtOnce.
if name == fileBrowserContainer {
go m.EnsureFileBrowserGate()
}
return m.RefreshStatus()
}