Documentation
¶
Overview ¶
Package configsftp adapts a connected sftp.Client to the config.FS interface, so a tool whose configuration lives on a remote host reached over SSH can hand that filesystem to a config Store unchanged.
sshClient, _ := ssh.Dial("tcp", host, sshCfg) // your transport + auth
sftpClient, _ := sftp.NewClient(sshClient) // your SFTP client
store, err := config.NewStore(ctx,
config.WithFiles(configsftp.Wrap(sftpClient), "/etc/app/config.yaml"))
SFTP is the least surprising remote adapter: it is not pretending to be a disk, it is a remote POSIX-shaped filesystem, so the six config.FS methods map almost directly onto the client. The adapter owns no connection and no credentials — every host, port, user, key and host-key decision lives in the SSH transport the consumer builds and hands in, exactly as afero's adapter takes an already-built afero.Fs.
What the adapter settles that a bare mapping does not ¶
Three things are load-bearing and worth not rediscovering:
Commit rename. A config write stages content beside the target and renames over it, so a reader never sees a half-written file. SFTP's plain rename is specified NOT to overwrite (OpenSSH refuses it), so the adapter prefers the posix-rename@openssh.com extension — an atomic replace of the destination — detecting per connection whether the server advertises it. Where it does not, the adapter falls back to plain Rename, and where that fails because the target exists, to Remove-then-Rename: a narrow non-atomic window. Atomic-overwrite commit therefore requires a server that supports posix-rename@openssh.com (OpenSSH and most others do); the atomicity is the remote server's, not the adapter's.
Watching. A remote SFTP path is not a local operating-system path, so the adapter deliberately does not implement config.RealPather — claiming it would send native notification after the wrong local file. That routes the Store to its poll watcher; because an SFTP poll is a network round-trip (though not a billed call), the adapter implements config.PollIntervalHinter to hint a 15-second cadence rather than the core's eager 2-second default. config.WithPollInterval still overrides the hint.
Symlinks. SFTP genuinely resolves symlinks, so the adapter implements config.LinkReader unconditionally — correct here precisely because it is always true for an SFTP server — and a configuration file reached through a remote link is written through it rather than replacing the link.
Index ¶
Examples ¶
Constants ¶
This section is empty.
Variables ¶
var ErrNoConn = errors.NewSentinel("configsftp.no_conn",
"no SSH connection supplied; dial one with ssh.Dial and pass it")
ErrNoConn reports a nil SSH connection supplied to FromSSH.
Functions ¶
func Wrap ¶
Wrap adapts a connected SFTP client to config.FS.
The returned value additionally satisfies config.LinkReader (SFTP resolves symlinks) and config.PollIntervalHinter (a 15-second poll cadence), and deliberately does not satisfy config.RealPather (a remote path is not a local one). The Store discovers each by type assertion, so no capability flag is exported.
Whether the server advertises the posix-rename@openssh.com extension is detected once, here, from the live connection — so the commit rename picks the strongest primitive the server actually offers.
Example ¶
ExampleWrap reads and writes configuration on a remote host over SFTP. You build the SSH transport — every host, port, user, key and host-key decision stays yours — build an *sftp.Client over it, and hand that to Wrap. The wrapped filesystem is passed to WithFiles exactly as config.OS() is.
package main
import (
"context"
"log"
"github.com/pkg/sftp"
"gitlab.com/phpboyscout/go/config"
"golang.org/x/crypto/ssh"
configsftp "gitlab.com/phpboyscout/go/config-sftp"
)
func main() {
sshClient, err := ssh.Dial("tcp", "config.example.com:22", &ssh.ClientConfig{
User: "deploy",
Auth: []ssh.AuthMethod{ /* your keys */ },
HostKeyCallback: ssh.FixedHostKey( /* your known host key */ nil),
})
if err != nil {
log.Fatal(err)
}
sftpClient, err := sftp.NewClient(sshClient)
if err != nil {
log.Fatal(err)
}
store, err := config.NewStore(context.Background(),
config.WithFiles(configsftp.Wrap(sftpClient), "/etc/app/config.yaml"),
)
if err != nil {
log.Fatal(err)
}
_ = store.View().GetInt("server.port")
}
Output:
Types ¶
type Option ¶ added in v0.3.0
type Option func(*settings)
Option configures the SFTP subsystem this rung opens. It exists so a caller can tune the client — packet size, concurrency — without reaching for sftp.NewClient themselves and losing the rung.
func WithClientOptions ¶ added in v0.3.0
func WithClientOptions(o ...sftp.ClientOption) Option
WithClientOptions passes the SFTP package's own options through to sftp.NewClient — MaxPacket, UseConcurrentReads and the rest.
type OwnedFS ¶ added in v0.3.0
OwnedFS is a config.FS that opened its own SFTP subsystem and can close it.
It closes the SFTP client only, never the ssh.Client underneath: that connection is the caller's, may carry other subsystems, and closing somebody else's transport because this filesystem is finished with it would be presumptuous.
conn, err := ssh.Dial("tcp", host, sshCfg) // yours; you close it
if err != nil { return err }
defer conn.Close()
fsys, err := configsftp.FromSSH(conn)
if err != nil { return err }
store, err := config.NewStore(ctx,
config.WithFiles(fsys, "/etc/app/config.yaml"),
config.WithCloser(fsys)) // closing the Store closes the subsystem only
if err != nil { return err }
defer store.Close() // runs before conn.Close
Wrap returns a plain config.FS, because there the caller opened the subsystem and still owns it — the obligation follows who built it, not which rung (spec 0012 L-7, R2).
func FromSSH ¶ added in v0.3.0
FromSSH opens an SFTP subsystem over an SSH connection the caller dialled, and returns a filesystem over it.
This rung does I/O, unlike every other rung in the family ¶
Elsewhere a rung 3 constructs a client without touching the network and defers reaching the service to the first read. There is no such split here: an SFTP client IS a subsystem channel plus a version handshake, so obtaining one is a round trip by definition. The connection is already established, so it is one exchange rather than a dial, and a failure names the subsystem rather than surfacing later as a confusing read error.
There is no ambient rung here, deliberately ¶
Every remote adapter in this family that has an ambient convention to adopt offers a zero-conf Default(). SFTP has one — an agent, ~/.ssh/config, known_hosts — and deliberately does not take it, because using it would mean choosing a HOST-KEY VERIFICATION POLICY on the caller's behalf.
Every available choice is wrong for somebody. Trust-on-first-use silently accepts a man in the middle. Requiring known_hosts fails exactly the unattended machines most likely to want zero configuration. Skipping verification is not something a configuration library should ever ship.
So the ladder stops here, on the record rather than by omission: build the ssh.ClientConfig yourself, decide your own HostKeyCallback, and hand over the connection (spec 0012 L-6).
func (*OwnedFS) Close ¶ added in v0.3.0
Close releases the SFTP subsystem this filesystem opened. The SSH connection beneath is untouched. It is safe to call more than once.
func (*OwnedFS) PollInterval ¶ added in v0.3.3
PollInterval forwards the owned filesystem's cadence hint, or reports none.