diff --git a/config.go b/config.go new file mode 100644 index 00000000..6dfec700 --- /dev/null +++ b/config.go @@ -0,0 +1,129 @@ +package main + +import ( + "fmt" + "io" + "reflect" + "strconv" + "strings" + "text/template" + "time" +) + +type Options struct { + Listen string `ini:"listen-address" default:":22000" description:"ip:port to for incoming sync connections"` + ReadOnly bool `ini:"read-only" description:"Allow changes to the local repository"` + Delete bool `ini:"allow-delete" default:"true" description:"Allow deletes of files in the local repository"` + Symlinks bool `ini:"follow-symlinks" default:"true" description:"Follow symbolic links at the top level of the repository"` + GUI bool `ini:"gui-enabled" default:"true" description:"Enable the HTTP GUI"` + GUIAddr string `ini:"gui-address" default:"127.0.0.1:8080" description:"ip:port for GUI connections"` + ExternalServer string `ini:"global-announce-server" default:"syncthing.nym.se:22025" description:"Global server for announcements"` + ExternalDiscovery bool `ini:"global-announce-enabled" default:"true" description:"Announce to the global announce server"` + LocalDiscovery bool `ini:"local-announce-enabled" default:"true" description:"Announce to the local network"` + RequestsInFlight int `ini:"parallell-requests" default:"16" description:"Maximum number of blocks to request in parallell"` + LimitRate int `ini:"max-send-kbps" description:"Limit outgoing data rate (kbyte/s)"` + ScanInterval time.Duration `ini:"rescan-interval" default:"60s" description:"Scan repository for changes this often"` + ConnInterval time.Duration `ini:"reconnection-interval" default:"60s" description:"Attempt to (re)connect to peers this often"` + MaxChangeBW int `ini:"max-change-bw" default:"1000" description:"Suppress files changing more than this (kbyte/s)"` +} + +func loadConfig(m map[string]string, data interface{}) error { + s := reflect.ValueOf(data).Elem() + t := s.Type() + + for i := 0; i < s.NumField(); i++ { + f := s.Field(i) + tag := t.Field(i).Tag + + name := tag.Get("ini") + if len(name) == 0 { + name = strings.ToLower(t.Field(i).Name) + } + + v, ok := m[name] + if !ok { + v = tag.Get("default") + } + if len(v) > 0 { + switch f.Interface().(type) { + case time.Duration: + d, err := time.ParseDuration(v) + if err != nil { + return err + } + f.SetInt(int64(d)) + + case string: + f.SetString(v) + + case int: + i, err := strconv.ParseInt(v, 10, 64) + if err != nil { + return err + } + f.SetInt(i) + + case bool: + f.SetBool(v == "true") + + default: + panic(f.Type()) + } + } + } + return nil +} + +type cfg struct { + Key string + Value string + Comment string +} + +func structToValues(data interface{}) []cfg { + s := reflect.ValueOf(data).Elem() + t := s.Type() + + var vals []cfg + for i := 0; i < s.NumField(); i++ { + f := s.Field(i) + tag := t.Field(i).Tag + + var c cfg + c.Key = tag.Get("ini") + if len(c.Key) == 0 { + c.Key = strings.ToLower(t.Field(i).Name) + } + c.Value = fmt.Sprint(f.Interface()) + c.Comment = tag.Get("description") + vals = append(vals, c) + } + return vals +} + +var configTemplateStr = `[repository] +{{if .comments}}; The directory to synchronize. Will be created if it does not exist. +{{end}}dir = {{.dir}} + +[nodes] +{{if .comments}}; Map of node ID to addresses, or "dynamic" for automatic discovery. Examples: +; J3MZ4G5O4CLHJKB25WX47K5NUJUWDOLO2TTNY3TV3NRU4HVQRKEQ = 172.16.32.24:22000 +; ZNJZRXQKYHF56A2VVNESRZ6AY4ZOWGFJCV6FXDZJUTRVR3SNBT6Q = dynamic +{{end}}{{range $n, $a := .nodes}}{{$n}} = {{$a}} +{{end}} +[settings] +{{range $v := .settings}}; {{$v.Comment}} +{{$v.Key}} = {{$v.Value}} +{{end}} +` + +var configTemplate = template.Must(template.New("config").Parse(configTemplateStr)) + +func writeConfig(wr io.Writer, dir string, nodes map[string]string, opts Options, comments bool) { + configTemplate.Execute(wr, map[string]interface{}{ + "dir": dir, + "nodes": nodes, + "settings": structToValues(&opts), + "comments": comments, + }) +} diff --git a/discover/discover.go b/discover/discover.go index fa638e77..d914494d 100644 --- a/discover/discover.go +++ b/discover/discover.go @@ -100,7 +100,6 @@ type Discoverer struct { MyID string ListenPort int BroadcastIntv time.Duration - ExtListenPort int ExtBroadcastIntv time.Duration conn *net.UDPConn @@ -114,7 +113,7 @@ type Discoverer struct { // When we hit this many errors in succession, we stop. const maxErrors = 30 -func NewDiscoverer(id string, port int, extPort int, extServer string) (*Discoverer, error) { +func NewDiscoverer(id string, port int, extServer string) (*Discoverer, error) { local4 := &net.UDPAddr{IP: net.IP{0, 0, 0, 0}, Port: AnnouncementPort} conn, err := net.ListenUDP("udp4", local4) if err != nil { @@ -125,7 +124,6 @@ func NewDiscoverer(id string, port int, extPort int, extServer string) (*Discove MyID: id, ListenPort: port, BroadcastIntv: 30 * time.Second, - ExtListenPort: extPort, ExtBroadcastIntv: 1800 * time.Second, conn: conn, @@ -138,7 +136,7 @@ func NewDiscoverer(id string, port int, extPort int, extServer string) (*Discove if disc.ListenPort > 0 { disc.sendAnnouncements() } - if len(disc.extServer) > 0 && disc.ExtListenPort > 0 { + if len(disc.extServer) > 0 { disc.sendExtAnnouncements() } @@ -153,13 +151,13 @@ func (d *Discoverer) sendAnnouncements() { } func (d *Discoverer) sendExtAnnouncements() { - extIP, err := net.ResolveUDPAddr("udp", d.extServer+":22025") + extIP, err := net.ResolveUDPAddr("udp", d.extServer) if err != nil { log.Printf("discover/external: %v; no external announcements", err) return } - buf := EncodePacket(Packet{AnnouncementMagic, uint16(d.ExtListenPort), d.MyID, nil}) + buf := EncodePacket(Packet{AnnouncementMagic, uint16(22000), d.MyID, nil}) go d.writeAnnouncements(buf, extIP, d.ExtBroadcastIntv) } @@ -213,7 +211,7 @@ func (d *Discoverer) recvAnnouncements() { } func (d *Discoverer) externalLookup(node string) (string, bool) { - extIP, err := net.ResolveUDPAddr("udp", d.extServer+":22025") + extIP, err := net.ResolveUDPAddr("udp", d.extServer) if err != nil { log.Printf("discover/external: %v; no external lookup", err) return "", false diff --git a/main.go b/main.go index cd6d97c8..a0123e92 100644 --- a/main.go +++ b/main.go @@ -3,6 +3,7 @@ package main import ( "compress/gzip" "crypto/tls" + "flag" "fmt" "log" "net" @@ -18,49 +19,10 @@ import ( "github.com/calmh/ini" "github.com/calmh/syncthing/discover" - flags "github.com/calmh/syncthing/github.com/jessevdk/go-flags" "github.com/calmh/syncthing/model" "github.com/calmh/syncthing/protocol" ) -type Options struct { - ConfDir string `short:"c" long:"cfg" description:"Configuration directory" default:"~/.syncthing" value-name:"DIR"` - Listen string `short:"l" long:"listen" description:"Listen address" default:":22000" value-name:"ADDR"` - ReadOnly bool `short:"r" long:"ro" description:"Repository is read only"` - Rehash bool `long:"rehash" description:"Ignore cache and rehash all files in repository"` - NoDelete bool `long:"no-delete" description:"Never delete files"` - NoSymlinks bool `long:"no-symlinks" description:"Don't follow first level symlinks in the repo"` - NoStats bool `long:"no-stats" description:"Don't print model and connection statistics"` - NoGUI bool `long:"no-gui" description:"Don't start GUI"` - GUIAddr string `long:"gui-addr" description:"GUI listen address" default:"127.0.0.1:8080" value-name:"ADDR"` - ShowVersion bool `short:"v" long:"version" description:"Show version"` - Discovery DiscoveryOptions `group:"Discovery Options"` - Advanced AdvancedOptions `group:"Advanced Options"` - Debug DebugOptions `group:"Debugging Options"` -} - -type DebugOptions struct { - LogSource bool `long:"log-source"` - TraceModel []string `long:"trace-model" value-name:"TRACE" description:"idx, net, file, need, pull"` - TraceConnect bool `long:"trace-connect"` - Profiler string `long:"profiler" value-name:"ADDR"` -} - -type DiscoveryOptions struct { - ExternalServer string `long:"ext-server" description:"External discovery server" value-name:"NAME" default:"syncthing.nym.se"` - ExternalPort int `short:"e" long:"ext-port" description:"External listen port" value-name:"PORT" default:"22000"` - NoExternalDiscovery bool `short:"n" long:"no-ext-announce" description:"Do not announce presence externally"` - NoLocalDiscovery bool `short:"N" long:"no-local-announce" description:"Do not announce presence locally"` -} - -type AdvancedOptions struct { - RequestsInFlight int `long:"reqs-in-flight" description:"Parallell in flight requests per node" default:"8" value-name:"REQS"` - LimitRate int `long:"send-rate" description:"Rate limit for outgoing data" default:"0" value-name:"KBPS"` - ScanInterval time.Duration `long:"scan-intv" description:"Repository scan interval" default:"60s" value-name:"INTV"` - ConnInterval time.Duration `long:"conn-intv" description:"Node reconnect interval" default:"60s" value-name:"INTV"` - MaxChangeBW int `long:"max-change-bw" description:"Max change bandwidth per file" default:"1e6" value-name:"MB/s"` -} - var opts Options var Version string = "unknown-dev" @@ -74,21 +36,27 @@ var ( nodeAddrs = make(map[string][]string) ) +var ( + showVersion bool + showConfig bool + confDir string + trace string + profiler string +) + func main() { log.SetOutput(os.Stderr) logger = log.New(os.Stderr, "", log.Flags()) - _, err := flags.Parse(&opts) - if err != nil { - if err, ok := err.(*flags.Error); ok { - if err.Type == flags.ErrHelp { - os.Exit(0) - } - } - fatalln(err) - } + flag.StringVar(&confDir, "home", "~/.syncthing", "Set configuration directory") + flag.BoolVar(&showConfig, "config", false, "Print current configuration") + flag.StringVar(&trace, "debug.trace", "", "(connect,net,idx,file,pull)") + flag.StringVar(&profiler, "debug.profiler", "", "(addr)") + flag.BoolVar(&showVersion, "version", false, "Show version") + flag.Usage = usageFor(flag.CommandLine, "syncthing [options]") + flag.Parse() - if opts.ShowVersion { + if showVersion { fmt.Println(Version) os.Exit(0) } @@ -101,63 +69,73 @@ func main() { runtime.GOMAXPROCS(runtime.NumCPU()) } - if len(opts.Debug.TraceModel) > 0 || opts.Debug.LogSource { + if len(trace) > 0 { log.SetFlags(log.Lshortfile | log.Ldate | log.Ltime | log.Lmicroseconds) logger.SetFlags(log.Lshortfile | log.Ldate | log.Ltime | log.Lmicroseconds) } - opts.ConfDir = expandTilde(opts.ConfDir) - - infoln("Version", Version) + confDir = expandTilde(confDir) // Ensure that our home directory exists and that we have a certificate and key. - ensureDir(opts.ConfDir, 0700) - cert, err := loadCert(opts.ConfDir) + ensureDir(confDir, 0700) + cert, err := loadCert(confDir) if err != nil { - newCertificate(opts.ConfDir) - cert, err = loadCert(opts.ConfDir) + newCertificate(confDir) + cert, err = loadCert(confDir) fatalErr(err) } myID = string(certId(cert.Certificate[0])) - infoln("My ID:", myID) log.SetPrefix("[" + myID[0:5] + "] ") logger.SetPrefix("[" + myID[0:5] + "] ") // Load the configuration file, if it exists. // If it does not, create a template. - cfgFile := path.Join(opts.ConfDir, confFileName) + cfgFile := path.Join(confDir, confFileName) cf, err := os.Open(cfgFile) if err != nil { + infoln("My ID:", myID) + infoln("No config file; creating a template") - config = ini.Config{} - config.AddComment("repository", "Set the following to the directory you wish to synchronize") - config.AddComment("repository", "dir = ~/Syncthing") - config.Set("nodes", myID, "auto") - config.AddComment("nodes", "Add peer nodes here") + + loadConfig(nil, &opts) //loads defaults fd, err := os.Create(cfgFile) if err != nil { fatalln(err) } - config.Write(fd) + + writeConfig(fd, "~/Sync", map[string]string{myID: "dynamic"}, opts, true) fd.Close() infof("Edit %s to suit and restart syncthing.", cfgFile) + os.Exit(0) } config = ini.Parse(cf) cf.Close() + loadConfig(config.OptionMap("settings"), &opts) + + if showConfig { + writeConfig(os.Stdout, + config.Get("repository", "dir"), + config.OptionMap("nodes"), opts, false) + os.Exit(0) + } + + infoln("Version", Version) + infoln("My ID:", myID) + var dir = expandTilde(config.Get("repository", "dir")) if len(dir) == 0 { fatalln("No repository directory. Set dir under [repository] in syncthing.ini.") } - if opts.Debug.Profiler != "" { + if len(profiler) > 0 { go func() { - err := http.ListenAndServe(opts.Debug.Profiler, nil) + err := http.ListenAndServe(profiler, nil) if err != nil { warnln(err) } @@ -186,16 +164,16 @@ func main() { } ensureDir(dir, -1) - m := model.NewModel(dir, opts.Advanced.MaxChangeBW) - for _, t := range opts.Debug.TraceModel { + m := model.NewModel(dir, opts.MaxChangeBW*1000) + for _, t := range strings.Split(trace, ",") { m.Trace(t) } - if opts.Advanced.LimitRate > 0 { - m.LimitRate(opts.Advanced.LimitRate) + if opts.LimitRate > 0 { + m.LimitRate(opts.LimitRate) } // GUI - if !opts.NoGUI && opts.GUIAddr != "" { + if opts.GUI && opts.GUIAddr != "" { host, port, err := net.SplitHostPort(opts.GUIAddr) if err != nil { warnf("Cannot start GUI on %q: %v", opts.GUIAddr, err) @@ -212,10 +190,6 @@ func main() { // Walk the repository and update the local model before establishing any // connections to other nodes. - if !opts.Rehash { - infoln("Loading index cache") - loadIndex(m) - } infoln("Populating repository index") updateLocalModel(m) @@ -230,13 +204,13 @@ func main() { // Routine to pull blocks from other nodes to synchronize the local // repository. Does not run when we are in read only (publish only) mode. if !opts.ReadOnly { - if opts.NoDelete { - infoln("Deletes from peer nodes will be ignored") - } else { + if opts.Delete { infoln("Deletes from peer nodes are allowed") + } else { + infoln("Deletes from peer nodes will be ignored") } okln("Ready to synchronize (read-write)") - m.StartRW(!opts.NoDelete, opts.Advanced.RequestsInFlight) + m.StartRW(opts.Delete, opts.RequestsInFlight) } else { okln("Ready to synchronize (read only; no external updates accepted)") } @@ -245,17 +219,15 @@ func main() { // XXX: Should use some fsnotify mechanism. go func() { for { - time.Sleep(opts.Advanced.ScanInterval) - if m.LocalAge() > opts.Advanced.ScanInterval.Seconds()/2 { + time.Sleep(opts.ScanInterval) + if m.LocalAge() > opts.ScanInterval.Seconds()/2 { updateLocalModel(m) } } }() - if !opts.NoStats { - // Periodically print statistics - go printStatsLoop(m) - } + // Periodically print statistics + go printStatsLoop(m) select {} } @@ -308,7 +280,7 @@ listen: continue } - if opts.Debug.TraceConnect { + if strings.Contains(trace, "connect") { debugln("NET: Connect from", conn.RemoteAddr()) } @@ -348,19 +320,19 @@ func connect(myID string, addr string, nodeAddrs map[string][]string, m *model.M fatalErr(err) port, _ := strconv.Atoi(portstr) - if opts.Discovery.NoLocalDiscovery { + if !opts.LocalDiscovery { port = -1 } else { infoln("Sending local discovery announcements") } - if opts.Discovery.NoExternalDiscovery { - opts.Discovery.ExternalPort = -1 + if !opts.ExternalDiscovery { + opts.ExternalServer = "" } else { infoln("Sending external discovery announcements") } - disc, err := discover.NewDiscoverer(myID, port, opts.Discovery.ExternalPort, opts.Discovery.ExternalServer) + disc, err := discover.NewDiscoverer(myID, port, opts.ExternalServer) if err != nil { warnf("No discovery possible (%v)", err) @@ -391,12 +363,12 @@ func connect(myID string, addr string, nodeAddrs map[string][]string, m *model.M } } - if opts.Debug.TraceConnect { + if strings.Contains(trace, "connect") { debugln("NET: Dial", nodeID, addr) } conn, err := tls.Dial("tcp", addr, cfg) if err != nil { - if opts.Debug.TraceConnect { + if strings.Contains(trace, "connect") { debugln("NET:", err) } continue @@ -415,19 +387,19 @@ func connect(myID string, addr string, nodeAddrs map[string][]string, m *model.M } } - time.Sleep(opts.Advanced.ConnInterval) + time.Sleep(opts.ConnInterval) } } func updateLocalModel(m *model.Model) { - files, _ := m.Walk(!opts.NoSymlinks) + files, _ := m.Walk(opts.Symlinks) m.ReplaceLocal(files) saveIndex(m) } func saveIndex(m *model.Model) { name := m.RepoID() + ".idx.gz" - fullName := path.Join(opts.ConfDir, name) + fullName := path.Join(confDir, name) idxf, err := os.Create(fullName + ".tmp") if err != nil { return @@ -443,7 +415,7 @@ func saveIndex(m *model.Model) { func loadIndex(m *model.Model) { name := m.RepoID() + ".idx.gz" - idxf, err := os.Open(path.Join(opts.ConfDir, name)) + idxf, err := os.Open(path.Join(confDir, name)) if err != nil { return } diff --git a/usage.go b/usage.go new file mode 100644 index 00000000..cfd1f303 --- /dev/null +++ b/usage.go @@ -0,0 +1,52 @@ +package main + +import ( + "bytes" + "flag" + "fmt" + "io" + "text/tabwriter" +) + +func optionTable(w io.Writer, rows [][]string) { + tw := tabwriter.NewWriter(w, 2, 4, 2, ' ', 0) + for _, row := range rows { + for i, cell := range row { + if i > 0 { + tw.Write([]byte("\t")) + } + tw.Write([]byte(cell)) + } + tw.Write([]byte("\n")) + } + tw.Flush() +} + +func usageFor(fs *flag.FlagSet, usage string) func() { + return func() { + var b bytes.Buffer + b.WriteString("Usage:\n " + usage + "\n") + + var options [][]string + fs.VisitAll(func(f *flag.Flag) { + var dash = "-" + if len(f.Name) > 1 { + dash = "--" + } + var opt = " " + dash + f.Name + + if f.DefValue != "false" { + opt += "=" + f.DefValue + } + + options = append(options, []string{opt, f.Usage}) + }) + + if len(options) > 0 { + b.WriteString("\nOptions:\n") + optionTable(&b, options) + } + + fmt.Println(b.String()) + } +}