phphost: resend a file the host cuts short; node panel address; chosen-token check (#146)

* phphost: a file the host cuts short is sent again; 'auto' leaves a failing TLS data channel for plain FTP

InfinityFree's Pure-FTPd answered '451 Transfer aborted' about 640 KB into the 2 MB
link parser (assets/share.wasm.gz) over a TLS data channel, and that one drop failed
the whole install; the same files went up whole in plain FTP with curl.

- each file is tried up to 4 times, connecting again before each try (after a drop
  the server often closes the control connection too)
- with TLS 'auto', two failed sends over a TLS data channel go on in plain FTP, as the
  host would have got with no TLS at all; the install then reports the security as
  'none' (the password crossed in the clear). 'explicit'/'implicit' never get weaker
- the TLS config has a session cache: servers that require data connections to resume
  the control connection's TLS session refused them before
- the page's link parser is optional: a host that will not take it gets a working
  node, and Installed.skipped says so
- progress has 'retry' and 'skipped' phases with the server's own words (note)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* phphost: 'page' gives the node's control panel address (auto=0: opening it only looks)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* phphost: a chosen token must be 8-64 of A-Z a-z 0-9 - _ (it goes into config.php and every address)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* CHANGELOG: the FTP upload-retry fix and the page/token additions, under 0.3.0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

---------

Co-authored-by: p1neappleXpress <a@a.a>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
p1neappleXpress
2026-10-01 20:02:50 +03:00
committed by GitHub
co-authored by Claude Opus 5.5 p1neappleXpress
parent acac97b07d
commit d245db7561
7 changed files with 289 additions and 19 deletions
+11
View File
@@ -81,6 +81,17 @@ All notable changes to the OpenFlux core. Format loosely follows
### Fixed
- The hosting install no longer fails when the host cuts a transfer short.
InfinityFree's Pure-FTPd aborted the 2 MB link parser part way through over a
TLS data channel (`451 Transfer aborted`), which failed the whole install;
the same files went up whole in plain FTP. Each file is now sent again (up to
four tries, reconnecting between them); with TLS `auto`, two failures over a
TLS data channel go on in plain FTP, as a host with no TLS would have, and the
reported security becomes `none`; the link parser is optional, so a host that
will not take it still gets a working node. `provision/phphost` also gained a
`page` call (the node's control-panel address, `auto=0` so opening it only
looks) and checks that a chosen token is 8–64 of `A–Z a–z 0–9 - _`.
- The mode-without-a-server node now survives any host's limits and keeps the
tunnel up across generations, found on a local emulation of a free host
(Apache + PHP-FPM, a 60 s CPU cap, the host's disabled functions, a hidden
+8 -1
View File
@@ -46,7 +46,8 @@ func RoomURL(uuid string) string { return "https://interview.cups.online/live-co
// check does the site answer as a node, can the host run it -> Status
// start run the node on Target (waits until it reports) -> NodeState
// stop end the node (the whole chain)
// node is the node on Target running -> NodeState
// node is the node on Target running, which generation -> NodeState
// page the node's control panel for a browser (auto=0) -> {"url"}
// newRoom make a cups.online room for the node and clients -> {"room","url"}
// link the openflux:// link clients scan for this node -> share.Result
func Call(ctx context.Context, method string, raw json.RawMessage, progress func(Progress)) Result {
@@ -111,6 +112,12 @@ func Call(ctx context.Context, method string, raw json.RawMessage, progress func
return Failed(err)
}
return Done(ns)
case "page":
u, err := site().PageURL(p.Target)
if err != nil {
return Failed(err)
}
return Done(map[string]string{"url": u})
case "newRoom":
packed, err := NewRoom(ctx)
if err != nil {
+21
View File
@@ -153,3 +153,24 @@ func TestCallStartWaitsForTheNode(t *testing.T) {
}
_ = time.Second
}
func TestCallPageIsTheNodesPanelThatOnlyLooks(t *testing.T) {
for _, c := range []struct{ carrier, file, target string }{
{"mailru", "/mailruexit.php", "https://cloud.mail.ru/public/Vuri/d5nuZ5aQp"},
{"cupsonline", "/cupsexit.php", "https://interview.cups.online/live-coding/?room=0a1b2c3d-1111-2222-3333-444455556666"},
} {
raw, _ := json.Marshal(map[string]string{"url": "https://site.example.org/", "token": "KEY1", "carrier": c.carrier, "target": c.target})
res := Call(context.Background(), "page", raw, nil)
if !res.OK {
t.Fatalf("%s: %+v", c.carrier, res)
}
u, err := url.Parse(res.Data.(map[string]string)["url"])
if err != nil {
t.Fatal(err)
}
q := u.Query()
if u.Host != "site.example.org" || u.Path != c.file || q.Get("k") != "KEY1" || q.Get("url") != c.target || q.Get("auto") != "0" {
t.Errorf("%s: page = %s", c.carrier, u)
}
}
}
+21 -1
View File
@@ -24,6 +24,8 @@ type fakeFTP struct {
dirs map[string]bool // "/htdocs"
readOnly bool // STOR answers 550
truncate bool // STOR keeps only half the bytes
abort map[string]int // path -> STORs left to cut short with "451 Transfer aborted" (as InfinityFree's Pure-FTPd did)
stors map[string]int // path -> STORs begun
}
func newFakeFTP(t *testing.T, user, pass string, dirs ...string) *fakeFTP {
@@ -31,7 +33,8 @@ func newFakeFTP(t *testing.T, user, pass string, dirs ...string) *fakeFTP {
if err != nil {
t.Fatal(err)
}
f := &fakeFTP{ln: ln, user: user, pass: pass, files: map[string][]byte{}, dirs: map[string]bool{"/": true}}
f := &fakeFTP{ln: ln, user: user, pass: pass, files: map[string][]byte{}, dirs: map[string]bool{"/": true},
abort: map[string]int{}, stors: map[string]int{}}
for _, d := range dirs {
f.dirs["/"+strings.Trim(d, "/")] = true
}
@@ -164,6 +167,23 @@ func (f *fakeFTP) session(c net.Conn) {
}
say("150 send it")
conn, _ := pasv.Accept()
f.mu.Lock()
f.stors[p]++
cut := f.abort[p] > 0
if cut {
f.abort[p]--
}
f.mu.Unlock()
if cut { // take a little, keep it, and hang up on the rest
part := make([]byte, 1024)
n, _ := io.ReadFull(conn, part)
conn.Close()
f.mu.Lock()
f.files[p] = part[:n]
f.mu.Unlock()
say(`451 Transfer aborted\n0.458 seconds (measured here), 1.40 Mbytes per second`)
continue
}
b, _ := io.ReadAll(conn)
conn.Close()
f.mu.Lock()
+104 -17
View File
@@ -80,8 +80,11 @@ func dial(ctx context.Context, t FTP) (*session, error) {
sec string
}
host := strings.TrimSpace(t.Host)
verified := &tls.Config{ServerName: host}
lax := &tls.Config{ServerName: host, InsecureSkipVerify: true}
// A session cache lets data connections resume the control connection's TLS session: servers
// that insist on it refuse a data connection that starts a new one.
cache := tls.NewLRUClientSessionCache(16)
verified := &tls.Config{ServerName: host, ClientSessionCache: cache}
lax := &tls.Config{ServerName: host, InsecureSkipVerify: true, ClientSessionCache: cache}
base := []ftp.DialOption{ftp.DialWithContext(ctx), ftp.DialWithTimeout(20 * time.Second)}
var attempts []attempt
switch t.TLS {
@@ -170,6 +173,12 @@ type Probe struct {
var tokenRe = regexp.MustCompile(`define\('PHPBOX_TOKEN',\s*'([0-9A-Za-z_-]+)'\)`)
var tokenShape = regexp.MustCompile(`^[0-9A-Za-z_-]{8,64}$`)
// TokenOK says whether a token someone chose can guard a node: it goes into config.php between quotes and into
// every page address, so only letters, digits, '-' and '_', and long enough not to be guessed.
func TokenOK(token string) bool { return tokenShape.MatchString(token) }
// ProbeHost logs in, finds the web root and checks it can be written to.
func ProbeHost(ctx context.Context, t FTP) (*Probe, error) {
s, err := dial(ctx, t)
@@ -309,12 +318,14 @@ func join(dir, name string) string {
// Progress is reported while Deploy uploads.
type Progress struct {
Phase string `json:"phase"` // "connect" | "probe" | "upload" | "done"
Phase string `json:"phase"` // "connect" | "probe" | "upload" | "retry" | "skipped" | "done"
File string `json:"file,omitempty"`
N int `json:"n"` // files finished
Of int `json:"of"` // files in all
BytesDone int64 `json:"bytes_done"` // over all files
BytesTotal int64 `json:"bytes_total"`
// Retry, skipped: what went wrong and what is tried next, for the app's log.
Note string `json:"note,omitempty"`
}
// Installed is what Deploy answers.
@@ -325,8 +336,22 @@ type Installed struct {
Bytes int64 `json:"bytes"`
Security string `json:"security"`
Reused bool `json:"token_reused"` // the node was installed before; its token was kept
// Skipped: optional files the host would not take (the page's link parser); the node runs without them.
Skipped []string `json:"skipped,omitempty"`
}
const (
uploadTries = 4 // times one file is sent before the install gives up on it
tlsTriesOnFile = 2 // failed sends over a TLS data channel before "auto" goes on in plain FTP
optionalFileWhy = "the page's link parser: the node runs without it, the page reads no links"
)
// retryPause is the wait before a file is sent again (a var: tests shorten it).
var retryPause = 2 * time.Second
// optional are files a node runs without: a host that will not take them gets the rest.
var optional = map[string]bool{"assets/share.wasm.gz": true}
type countingReader struct {
r io.Reader
done *int64
@@ -348,6 +373,9 @@ func Deploy(ctx context.Context, t FTP, token string, progress func(Progress)) (
if progress == nil {
progress = func(Progress) {}
}
if token != "" && !TokenOK(token) {
return nil, fail(CodeBadParams, "token", "phphost: a token is 8 to 64 of A-Z a-z 0-9 - _", nil)
}
progress(Progress{Phase: "connect"})
s, err := dial(ctx, t)
if err != nil {
@@ -383,27 +411,86 @@ func Deploy(ctx context.Context, t FTP, token string, progress func(Progress)) (
_ = s.c.MakeDir(join(p.Dir, d)) // exists already on a reinstall
}
var done int64
var skipped []string
security := s.security
for i, f := range files {
if ctx.Err() != nil {
return nil, fail(CodeUpload, f.Path, "phphost: cancelled", ctx.Err())
}
start := done
cr := &countingReader{r: bytes.NewReader(f.Data), done: &done}
cr.tick = func() {
progress(Progress{Phase: "upload", File: f.Path, N: i, Of: len(files), BytesDone: done, BytesTotal: total})
}
if err := s.c.Stor(join(p.Dir, f.Path), cr); err != nil {
return nil, fail(CodeUpload, f.Path, "phphost: upload failed", err)
for try := 1; ; try++ {
if ctx.Err() != nil {
return nil, fail(CodeUpload, f.Path, "phphost: cancelled", ctx.Err())
}
done = start
err := s.put(join(p.Dir, f.Path), f.Data, func() {
progress(Progress{Phase: "upload", File: f.Path, N: i, Of: len(files), BytesDone: done, BytesTotal: total})
}, &done)
if err == nil {
break
}
// Free hosts drop transfers (InfinityFree's Pure-FTPd: "451 Transfer aborted" part way through a 2 MB file over
// a TLS data channel, while curl in plain FTP sent it whole). Connect again and send the file again; after
// two failures over TLS, "auto" goes on in plain FTP, as it would have if the host had no TLS at all.
if try >= uploadTries {
if optional[f.Path] {
skipped = append(skipped, f.Path)
done = start + int64(len(f.Data))
progress(Progress{Phase: "skipped", File: f.Path, N: i + 1, Of: len(files), BytesDone: done, BytesTotal: total,
Note: fmt.Sprintf("%s: %v (%s)", f.Path, err, optionalFileWhy)})
break
}
return nil, fail(CodeUpload, f.Path, "phphost: upload failed", err)
}
mode := redialMode(t.TLS, s.security, try)
note := fmt.Sprintf("%s: %v; try %d of %d", f.Path, err, try+1, uploadTries)
if mode == "none" && s.security != SecurityNone {
note += ", now in plain FTP"
}
progress(Progress{Phase: "retry", File: f.Path, N: i, Of: len(files), BytesDone: start, BytesTotal: total, Note: note})
s.close()
select {
case <-ctx.Done():
return nil, fail(CodeUpload, f.Path, "phphost: cancelled", ctx.Err())
case <-time.After(retryPause):
}
again := t
again.TLS = mode
if s, err = dial(ctx, again); err != nil {
return nil, err
}
defer s.close()
if s.security == SecurityNone {
security = SecurityNone // the password has crossed in the clear: say so
}
}
done = start + int64(len(f.Data))
progress(Progress{Phase: "upload", File: f.Path, N: i + 1, Of: len(files), BytesDone: done, BytesTotal: total})
// A truncated upload is the usual silent failure on shared hosting: check the size where the server can say.
if sz, err := s.c.FileSize(join(p.Dir, f.Path)); err == nil && sz != int64(len(f.Data)) {
return nil, fail(CodeUpload, f.Path, fmt.Sprintf("phphost: %s arrived as %d bytes, sent %d", f.Path, sz, len(f.Data)), nil)
}
}
progress(Progress{Phase: "done", N: len(files), Of: len(files), BytesDone: total, BytesTotal: total})
return &Installed{Dir: p.Dir, Token: token, Files: len(files), Bytes: total, Security: s.security, Reused: reused}, nil
return &Installed{Dir: p.Dir, Token: token, Files: len(files) - len(skipped), Bytes: total, Security: security, Reused: reused, Skipped: skipped}, nil
}
// put sends one file whole, then checks its size where the server can say: a truncated upload is the usual
// silent failure on shared hosting.
func (s *session) put(remote string, data []byte, tick func(), done *int64) error {
cr := &countingReader{r: bytes.NewReader(data), done: done, tick: tick}
if err := s.c.Stor(remote, cr); err != nil {
return err
}
if sz, err := s.c.FileSize(remote); err == nil && sz != int64(len(data)) {
return fmt.Errorf("arrived as %d bytes, sent %d", sz, len(data))
}
return nil
}
// redialMode is the TLS mode to connect with again after a file failed on its try'th send: the same as before,
// except that "auto" leaves a TLS data channel that failed tlsTriesOnFile times for plain FTP.
func redialMode(asked, got string, try int) string {
if asked == "" || asked == "auto" {
if got == SecurityNone || try >= tlsTriesOnFile {
return "none"
}
return "auto"
}
return asked
}
// configPHP is config.php: the token as a constant (putenv is disabled on most
+118
View File
@@ -153,6 +153,7 @@ func TestDeployUploadsTheBundleAndKeepsTheTokenOnReinstall(t *testing.T) {
}
func TestDeployCatchesATruncatedUpload(t *testing.T) {
quickRetries(t)
srv := newFakeFTP(t, "u", "pw", "htdocs")
srv.truncate = true
_, err := Deploy(ctx(t), srv.target(), "", nil)
@@ -161,6 +162,107 @@ func TestDeployCatchesATruncatedUpload(t *testing.T) {
}
}
func quickRetries(t *testing.T) {
old := retryPause
retryPause = time.Millisecond
t.Cleanup(func() { retryPause = old })
}
// InfinityFree's Pure-FTPd cut the 2 MB parser short ("451 Transfer aborted") and the whole install failed on it.
func TestDeploySendsADroppedFileAgain(t *testing.T) {
quickRetries(t)
srv := newFakeFTP(t, "u", "pw", "htdocs")
srv.abort["/htdocs/lib/mux.php"] = 2
var retries []Progress
in, err := Deploy(ctx(t), srv.target(), "", func(p Progress) {
if p.Phase == "retry" {
retries = append(retries, p)
}
})
if err != nil {
t.Fatal(err)
}
for _, f := range bundle.Files() {
if got := srv.files["/htdocs/"+f.Path]; string(got) != string(f.Data) {
t.Errorf("%s was not uploaded intact (%d bytes there)", f.Path, len(got))
}
}
if len(retries) != 2 || retries[0].File != "lib/mux.php" || !strings.Contains(retries[0].Note, "Transfer aborted") {
t.Errorf("retries = %+v", retries)
}
if srv.stors["/htdocs/lib/mux.php"] != 3 || len(in.Skipped) != 0 {
t.Errorf("mux.php sent %d times, skipped %v", srv.stors["/htdocs/lib/mux.php"], in.Skipped)
}
}
func TestDeployGivesUpOnAFileTheHostKeepsRefusing(t *testing.T) {
quickRetries(t)
srv := newFakeFTP(t, "u", "pw", "htdocs")
srv.abort["/htdocs/lib/mux.php"] = 100
_, err := Deploy(ctx(t), srv.target(), "", nil)
var e *Error
if !errors.As(err, &e) || e.Code != CodeUpload || e.Param != "lib/mux.php" {
t.Fatalf("err = %v", err)
}
if !strings.Contains(err.Error(), "451") {
t.Errorf("the host's own words are lost: %v", err)
}
if n := srv.stors["/htdocs/lib/mux.php"]; n != uploadTries {
t.Errorf("mux.php sent %d times, want %d", n, uploadTries)
}
}
// The parser only serves the page in a browser: a host that will not take it still gets a working node.
func TestDeployGoesOnWithoutTheOptionalParser(t *testing.T) {
quickRetries(t)
srv := newFakeFTP(t, "u", "pw", "htdocs")
srv.abort["/htdocs/assets/share.wasm.gz"] = 100
var skipped []Progress
in, err := Deploy(ctx(t), srv.target(), "", func(p Progress) {
if p.Phase == "skipped" {
skipped = append(skipped, p)
}
})
if err != nil {
t.Fatal(err)
}
if len(in.Skipped) != 1 || in.Skipped[0] != "assets/share.wasm.gz" || len(skipped) != 1 {
t.Fatalf("skipped = %v / %+v", in.Skipped, skipped)
}
for _, f := range bundle.Files() {
if f.Path == "assets/share.wasm.gz" {
continue
}
if got := srv.files["/htdocs/"+f.Path]; string(got) != string(f.Data) {
t.Errorf("%s was not uploaded intact", f.Path)
}
}
if !strings.Contains(string(srv.files["/htdocs/config.php"]), in.Token) {
t.Error("config.php missing: the node would not run")
}
}
func TestOnlyAutoLeavesTLSForPlainFTP(t *testing.T) {
cases := []struct {
asked, got string
try int
want string
}{
{"auto", SecurityTLS, 1, "auto"}, // one drop: TLS again
{"auto", SecurityTLSUnverified, 2, "none"}, // two over TLS: plain, as curl
{"", SecurityTLS, 2, "none"},
{"auto", SecurityNone, 1, "none"},
{"explicit", SecurityTLS, 3, "explicit"}, // the user asked for TLS: never weaker
{"implicit", SecurityTLS, 3, "implicit"},
{"none", SecurityNone, 1, "none"},
}
for _, c := range cases {
if got := redialMode(c.asked, c.got, c.try); got != c.want {
t.Errorf("redialMode(%q, %q, %d) = %q, want %q", c.asked, c.got, c.try, got, c.want)
}
}
}
func TestRemoveDeletesOnlyWhatWasUploaded(t *testing.T) {
srv := newFakeFTP(t, "u", "pw", "htdocs")
srv.files["/htdocs/mysite.html"] = []byte("<p>mine</p>")
@@ -219,3 +321,19 @@ func TestProbeFindsAWebRootBelowTheLoginFolder(t *testing.T) {
t.Errorf("sites/shop/www: %+v %v", p, err)
}
}
func TestDeployTakesAChosenTokenOnlyOfASafeShape(t *testing.T) {
srv := newFakeFTP(t, "u", "pw", "htdocs")
for _, bad := range []string{"short", "has space in it", "quote'breaks-config", strings.Repeat("a", 65)} {
if _, err := Deploy(ctx(t), srv.target(), bad, nil); code(err) != CodeBadParams {
t.Errorf("token %q was taken: %v", bad, err)
}
}
if len(srv.files) != 0 {
t.Error("files were uploaded before the token was checked")
}
in, err := Deploy(ctx(t), srv.target(), "My-own_key-2026", nil)
if err != nil || in.Token != "My-own_key-2026" {
t.Fatalf("a good chosen token: %+v, %v", in, err)
}
}
+6
View File
@@ -60,6 +60,12 @@ func (s *Site) endpoint(q url.Values) (string, error) {
return u.String(), nil
}
// PageURL is the node's control panel for a person's browser: its status, log, Start/Stop and the generation
// serving now. auto=0: opening it only looks; it does not start a node that is not running.
func (s *Site) PageURL(target string) (string, error) {
return s.endpoint(url.Values{"url": {strings.TrimSpace(target)}, "auto": {"0"}})
}
// The browser check some free hosts (InfinityFree and other iFastNet sites) put in
// front of every page: a script decrypts a value with AES-128-CBC and sets it as the
// cookie "__test", then reloads with ?i=1. A browser does that unseen; so can we.