diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..78a7bf6 --- /dev/null +++ b/.gitignore @@ -0,0 +1,135 @@ +# Created by https://www.toptal.com/developers/gitignore/api/goland+all,go,linux +# Edit at https://www.toptal.com/developers/gitignore?templates=goland+all,go,linux + +### Go ### +# If you prefer the allow list template instead of the deny list, see community template: +# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore +# +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib + +# Test binary, built with `go test -c` +*.test + +# Output of the go coverage tool, specifically when used with LiteIDE +*.out + +# Dependency directories (remove the comment below to include it) +# vendor/ + +# Go workspace file +go.work + +### Go Patch ### +/vendor/ +/Godeps/ + +### GoLand+all ### +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/**/usage.statistics.xml +.idea/**/dictionaries +.idea/**/shelf + +# AWS User-specific +.idea/**/aws.xml + +# Generated files +.idea/**/contentModel.xml + +# Sensitive or high-churn files +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.local.xml +.idea/**/sqlDataSources.xml +.idea/**/dynamic.xml +.idea/**/uiDesigner.xml +.idea/**/dbnavigator.xml + +# Gradle +.idea/**/gradle.xml +.idea/**/libraries + +# Gradle and Maven with auto-import +# When using Gradle or Maven with auto-import, you should exclude module files, +# since they will be recreated, and may cause churn. Uncomment if using +# auto-import. +# .idea/artifacts +# .idea/compiler.xml +# .idea/jarRepositories.xml +# .idea/modules.xml +# .idea/*.iml +# .idea/modules +# *.iml +# *.ipr + +# CMake +cmake-build-*/ + +# Mongo Explorer plugin +.idea/**/mongoSettings.xml + +# File-based project format +*.iws + +# IntelliJ +out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Cursive Clojure plugin +.idea/replstate.xml + +# SonarLint plugin +.idea/sonarlint/ + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Editor-based Rest Client +.idea/httpRequests + +# Android studio 3.1+ serialized cache file +.idea/caches/build_file_checksums.ser + +### GoLand+all Patch ### +# Ignore everything but code style settings and run configurations +# that are supposed to be shared within teams. + +.idea/* + +!.idea/codeStyles +!.idea/runConfigurations + +### Linux ### +*~ + +# temporary files which can be created if a process still has a handle open of a deleted file +.fuse_hidden* + +# KDE directory preferences +.directory + +# Linux trash folder which might appear on any partition or disk +.Trash-* + +# .nfs files are created when an open file is removed but is still being accessed +.nfs* + +# End of https://www.toptal.com/developers/gitignore/api/goland+all,go,linux + diff --git a/config.yaml b/config.yaml new file mode 100644 index 0000000..12ee688 --- /dev/null +++ b/config.yaml @@ -0,0 +1,13 @@ +bandwidth: + min: 2 + max: 45 + step: 2 +interval: 5 +host: "itsh.dev" +ppp: 5 +conformation_ppp: 30 +log_level: DEBUG +upload_interface: "enp1s0f1" +cake_options: "docsis diffserv4 ack-filter nat" +throttle_ping_threshold: 50 +try_restoring_after: 30 \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b245cf5 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module uploadBaker + +go 1.18 + +require ( + github.com/go-ping/ping v1.1.0 + github.com/sirupsen/logrus v1.8.1 + github.com/wercker/journalhook v0.0.0-20180428041537-5d0a5ae867b3 + gopkg.in/yaml.v2 v2.4.0 +) + +require ( + github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf // indirect + github.com/google/uuid v1.2.0 // indirect + golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4 // indirect + golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect + golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..34b1208 --- /dev/null +++ b/go.sum @@ -0,0 +1,31 @@ +github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf h1:iW4rZ826su+pqaw19uhpSCzhj44qo35pNgKFGqzDKkU= +github.com/coreos/go-systemd v0.0.0-20191104093116-d3cd4ed1dbcf/go.mod h1:F5haX7vjVVG0kc13fIWeqUViNPyEJxv/OmvnBo0Yme4= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-ping/ping v1.1.0 h1:3MCGhVX4fyEUuhsfwPrsEdQw6xspHkv5zHsiSoDFZYw= +github.com/go-ping/ping v1.1.0/go.mod h1:xIFjORFzTxqIV/tDVGO4eDy/bLuSyawEeojSm3GfRGk= +github.com/google/uuid v1.2.0 h1:qJYtXnJRWmpe7m/3XlyhrsLrEURqHRM2kxzoxXqyUDs= +github.com/google/uuid v1.2.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sirupsen/logrus v1.8.1 h1:dJKuHgqk1NNQlqoA6BTlM1Wf9DOH3NBjQyu0h9+AZZE= +github.com/sirupsen/logrus v1.8.1/go.mod h1:yWOB1SBYBC5VeMP7gHvWumXLIWorT60ONWic61uBYv0= +github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= +github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/wercker/journalhook v0.0.0-20180428041537-5d0a5ae867b3 h1:shC1HB1UogxN5Ech3Yqaaxj1X/P656PPCB4RbojIJqc= +github.com/wercker/journalhook v0.0.0-20180428041537-5d0a5ae867b3/go.mod h1:XCsSkdKK4gwBMNrOCZWww0pX6AOt+2gYc5Z6jBRrNVg= +golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4 h1:b0LrWgu8+q7z4J+0Y3Umo5q1dL7NXBkKBWkaVkAq17E= +golang.org/x/net v0.0.0-20210316092652-d523dce5a7f4/go.mod h1:RBQZq4jEuRlivfhVLdyRGr576XBO4/greRjx4P4O3yc= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= +golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005 h1:pDMpM2zh2MT0kHy037cKlSby2nEhD50SYqwQk76Nm40= +golang.org/x/sys v0.0.0-20210315160823-c6e025ad8005/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= diff --git a/main.go b/main.go new file mode 100644 index 0000000..d0a8b7f --- /dev/null +++ b/main.go @@ -0,0 +1,202 @@ +package main + +import ( + "flag" + "fmt" + "github.com/go-ping/ping" + log "github.com/sirupsen/logrus" + "github.com/wercker/journalhook" + "gopkg.in/yaml.v2" + "os" + "os/exec" + "os/signal" + "regexp" + "strconv" + "strings" + "syscall" + "time" +) + +type Conf struct { + Bandwidth struct { + Max int `yaml:"max"` + Min int `yaml:"min"` + Step int `yaml:"step"` + } `yaml:"bandwidth"` + Interval float64 `yaml:"interval"` + Host string `yaml:"host"` + PPP int `yaml:"ppp"` + ConformationPPP int `yaml:"conformation_ppp"` + LogLevel string `yaml:"log_level"` + UploadInterface string `yaml:"upload_interface"` + CakeOptions string `yaml:"cake_options"` + ThrottlePingThreshold int64 `yaml:"throttle_ping_threshold"` + TryRestoringAfter int `yaml:"try_restoring_after"` +} + +var ( + conf *Conf + journalLog = flag.Bool("journal", false, "Log to systemd journal instead of stdout") + reBandwidth = regexp.MustCompile(`(?m)bandwidth\s(\d+)Mbit`) +) + +type UploadManager struct { + LastOver time.Time +} + +func (m UploadManager) Upload() (int, error) { + tcCmd := exec.Command("tc", "qdisc", "show", "dev", conf.UploadInterface) + out, err := tcCmd.CombinedOutput() + if err != nil { + return 0, err + } + if reBandwidth.MatchString(string(out)) { + up, err := strconv.Atoi(reBandwidth.FindAllStringSubmatch(string(out), -1)[0][1]) + if err != nil { + return 0, err + } + return up, nil + } else { + return 0, fmt.Errorf("could not parse bandwidth from tc") + } +} + +func (m UploadManager) SetUpload(upload int) error { + m.clearTC() + args := []string{"qdisc", "add", "dev", conf.UploadInterface, "root", "cake", "bandwidth", fmt.Sprintf("%dMbit", upload)} + args = append(args, strings.Split(conf.CakeOptions, " ")...) + tcCmd := exec.Command("tc", args...) + log.Debugf("[TC] exec %s", tcCmd.String()) + out, err := tcCmd.CombinedOutput() + if err != nil { + log.Debugf("[TC] executing %s failed with: %v (%s)", tcCmd.String(), err, out) + return err + } + return nil +} + +func (m UploadManager) clearTC() { + tcCmd := exec.Command("tc", "qdisc", "del", "dev", conf.UploadInterface, "root") + log.Debugf("[TC] exec %s", tcCmd.String()) + out, err := tcCmd.CombinedOutput() + if err != nil { + log.Debugf("[TC] executing %s failed with: %v (%s)", tcCmd.String(), err, out) + } +} + +func doPing(host string, count int, interval time.Duration) (*ping.Statistics, error) { + pinger, err := ping.NewPinger(host) + if err != nil { + return nil, err + } + pinger.SetPrivileged(true) + pinger.Count = count + pinger.Interval = interval + err = pinger.Run() + if err != nil { + return nil, err + } + return pinger.Statistics(), nil +} + +func (m UploadManager) isBWInRange(bw int) bool { + return bw <= conf.Bandwidth.Max && bw >= conf.Bandwidth.Min +} + +func (m UploadManager) pingWorker() error { + for { + stats, err := doPing(conf.Host, conf.PPP, time.Second) + if err != nil { + log.Warningf("ping to %s failed: %v", conf.Host, err) + time.Sleep(time.Duration(conf.Interval) * time.Minute) + continue + } + + log.Debugf("[PING] %s±%s", stats.AvgRtt, stats.StdDevRtt) + + if stats.AvgRtt.Milliseconds() >= conf.ThrottlePingThreshold { + m.LastOver = time.Now() + up, err := m.Upload() + if err != nil { + return err + } + + if m.isBWInRange(up - conf.Bandwidth.Step) { + log.Infof("Ping over threshold (%s) detected, confirming...", stats.AvgRtt) + + stats, err = doPing(conf.Host, conf.ConformationPPP, time.Second) + if err != nil { + log.Warningf("ping to %s failed: %v", conf.Host, err) + time.Sleep(time.Duration(conf.Interval) * time.Minute) + continue + } + + if stats.AvgRtt.Milliseconds() >= conf.ThrottlePingThreshold { + log.Infof("Ping confirmed, adjusting upload TC %d->%d", up, up-conf.Bandwidth.Step) + err = m.SetUpload(up - conf.Bandwidth.Step) + if err != nil { + return err + } + m.LastOver = time.Now() + } + } else { + log.Infof("Ping over threshold (%s) detected, but no bandwidth available", stats.AvgRtt) + m.LastOver = time.Now() + } + } else if time.Since(m.LastOver) >= time.Duration(conf.TryRestoringAfter)*time.Minute { + up, err := m.Upload() + if err != nil { + return err + } + if m.isBWInRange(up + conf.Bandwidth.Step) { + log.Infof("trying to increase upload TC %d->%d", up, up+conf.Bandwidth.Step) + err = m.SetUpload(up + conf.Bandwidth.Step) + if err != nil { + return err + } + m.LastOver = time.Now() + } + } + time.Sleep(time.Duration(conf.Interval) * time.Minute) + } +} + +func main() { + killSignals := make(chan os.Signal, 1) + signal.Notify(killSignals, syscall.SIGINT, syscall.SIGTERM) + reloadSignals := make(chan os.Signal, 1) + signal.Notify(reloadSignals, syscall.SIGUSR1) + flag.Parse() + confStr, err := os.ReadFile("config.yaml") + if err != nil { + log.Fatalf("Error reading config file: %v", err) + } + err = yaml.Unmarshal(confStr, &conf) + if err != nil { + log.Fatalf("Error parsing config file: %v", err) + } + lvl, err := log.ParseLevel(conf.LogLevel) + if err != nil { + log.Fatalf("Error parsing log level from config: %v", err) + } + log.SetLevel(lvl) + if *journalLog { + journalhook.Enable() + } + + um := new(UploadManager) + go func() { + err := um.pingWorker() + if err != nil { + log.Fatalf("Error in ping-loop: %v", err) + } + }() + +killLoop: + for { + select { + case <-killSignals: + break killLoop + } + } +}