├── VERSION ├── go.mod ├── server ├── path_test.go ├── path.go └── server.go ├── .github └── workflows │ └── build.yml ├── LICENSE ├── go.sum ├── .gitignore ├── integration_test.go ├── README.md └── main.go /VERSION: -------------------------------------------------------------------------------- 1 | 1.2.0 -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/puhitaku/tftp-now 2 | 3 | go 1.24 4 | 5 | require ( 6 | github.com/oklog/ulid/v2 v2.1.0 7 | github.com/pin/tftp/v3 v3.0.0 8 | github.com/rs/zerolog v1.29.0 9 | golang.org/x/sync v0.1.0 10 | ) 11 | 12 | require ( 13 | github.com/mattn/go-colorable v0.1.13 // indirect 14 | github.com/mattn/go-isatty v0.0.17 // indirect 15 | golang.org/x/net v0.8.0 // indirect 16 | golang.org/x/sys v0.6.0 // indirect 17 | ) 18 | -------------------------------------------------------------------------------- /server/path_test.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | "testing" 8 | ) 9 | 10 | func TestTrustedRoot(t *testing.T) { 11 | actualTemp, err := filepath.EvalSymlinks(os.TempDir()) 12 | if err != nil { 13 | t.Fatalf("failed to get the actual path of the temp dir: %s", err) 14 | } 15 | 16 | root := strings.TrimRight(actualTemp, "/") 17 | rootName := filepath.Base(root) 18 | defer os.RemoveAll(root) 19 | 20 | err = touch(filepath.Join(root, "foo")) 21 | if err != nil { 22 | t.Fatalf("failed to create foo: %s", err) 23 | } 24 | 25 | err = os.Symlink("/etc/passwd", filepath.Join(root, "symlink")) 26 | if err != nil { 27 | t.Fatalf("failed to create a symlink: %s", err) 28 | } 29 | 30 | var testCases = []struct { 31 | name string 32 | dir string 33 | trusted bool 34 | validLink bool 35 | }{ 36 | { 37 | name: "trusted root itself", 38 | dir: ".", 39 | trusted: true, 40 | }, 41 | { 42 | name: "parent of trusted root", 43 | dir: "..", 44 | trusted: false, 45 | }, 46 | { 47 | name: "trusted root itself, but redundant", 48 | dir: "../" + rootName, 49 | trusted: true, 50 | }, 51 | { 52 | name: "single slash should point the trusted root", 53 | dir: "/", 54 | trusted: true, 55 | }, 56 | { 57 | name: "link to /etc/passwd", 58 | dir: "symlink", 59 | trusted: false, 60 | }, 61 | } 62 | 63 | for _, c := range testCases { 64 | t.Run(c.name, func(t *testing.T) { 65 | _, err := evaluatePath(c.dir, root, true) 66 | if actual := err == nil; actual != c.trusted { 67 | t.Errorf("unexpected trust, expect: %t, actual: %t", c.trusted, err == nil) 68 | } 69 | }) 70 | } 71 | } 72 | 73 | func touch(path string) error { 74 | f, err := os.Create(path) 75 | defer f.Close() 76 | return err 77 | } 78 | -------------------------------------------------------------------------------- /server/path.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | 7 | "github.com/rs/zerolog" 8 | ) 9 | 10 | type InvalidPathError struct { 11 | PathRequest string 12 | PathAbs string 13 | PathEval string 14 | Reason reason 15 | 16 | BaseErr error 17 | } 18 | 19 | type reason string 20 | 21 | const ( 22 | AbsFail reason = "failed to get the absolute path" 23 | EvalFail reason = "failed to evaluate symlinks" 24 | OutOfRoot reason = "out of the root directory" 25 | ) 26 | 27 | func (i InvalidPathError) Error() string { 28 | // Prevent unexpected exposure of the actual reason, for security 29 | return "invalid path, or not found" 30 | } 31 | 32 | func (i InvalidPathError) Unwrap() error { 33 | return i.BaseErr 34 | } 35 | 36 | func (i InvalidPathError) MarshalZerologObject(e *zerolog.Event) { 37 | e.Str("pathRequest", i.PathRequest) 38 | if i.PathAbs != "" { 39 | e.Str("pathAbs", i.PathAbs) 40 | } 41 | if i.PathEval != "" { 42 | e.Str("pathEval", i.PathEval) 43 | } 44 | e.Str("reason", string(i.Reason)) 45 | e.AnErr("baseErr", i.BaseErr) 46 | } 47 | 48 | func evaluatePath(requestPath, trustedRoot string, evalSymlink bool) (string, error) { 49 | joined := filepath.Join(trustedRoot, requestPath) // Join() also cleans the path 50 | abs, err := filepath.Abs(joined) 51 | if err != nil { 52 | return "", InvalidPathError{ 53 | PathRequest: requestPath, 54 | Reason: AbsFail, 55 | BaseErr: err, 56 | } 57 | } 58 | 59 | evaluated := abs 60 | 61 | if evalSymlink { 62 | evaluated, err = filepath.EvalSymlinks(abs) 63 | if err != nil { 64 | return "", InvalidPathError{ 65 | PathRequest: requestPath, 66 | PathAbs: abs, 67 | Reason: EvalFail, 68 | BaseErr: err, 69 | } 70 | } 71 | } 72 | 73 | trusted := strings.HasPrefix(evaluated, trustedRoot) 74 | if !trusted { 75 | return "", InvalidPathError{ 76 | PathRequest: requestPath, 77 | PathAbs: abs, 78 | PathEval: evaluated, 79 | Reason: OutOfRoot, 80 | BaseErr: err, 81 | } 82 | } 83 | 84 | return evaluated, nil 85 | } 86 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | name: Build and Release 2 | 3 | on: 4 | push: 5 | branches: ci* 6 | tags: '*' 7 | 8 | jobs: 9 | create-release: 10 | name: Create Release 11 | runs-on: ubuntu-latest 12 | outputs: 13 | upload_url: ${{ steps.create_release.outputs.upload_url }} 14 | steps: 15 | - name: Create release 16 | id: create_release 17 | uses: actions/create-release@v1 18 | env: 19 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 20 | with: 21 | tag_name: ${{ github.ref_name }} 22 | release_name: ${{ github.ref_name }} 23 | draft: false 24 | prerelease: true 25 | 26 | build-and-release: 27 | runs-on: ubuntu-latest 28 | needs: [create-release] 29 | strategy: 30 | matrix: 31 | os: [linux, darwin, windows] 32 | goarch: [amd64, arm64] 33 | include: 34 | - os: linux 35 | goarch: riscv64 36 | - os: linux 37 | goarch: arm 38 | - os: linux 39 | goarch: mipsle 40 | - os: linux 41 | goarch: mips64le 42 | steps: 43 | - name: Checkout code 44 | uses: actions/checkout@v2 45 | - name: Setup Go 46 | uses: actions/setup-go@v2 47 | with: 48 | go-version: '1.24.x' 49 | - name: Build 50 | env: 51 | GOOS: ${{ matrix.os }} 52 | GOARCH: ${{ matrix.goarch }} 53 | run: | 54 | go env 55 | go version 56 | go build -tags netgo -ldflags='-s -w' -o tftp-now-${{ matrix.os }}-${{ matrix.GOARCH }}${{ matrix.os == 'windows' && '.exe' || '' }} . 57 | - name: Upload Release Asset 58 | env: 59 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 60 | uses: actions/upload-release-asset@v1 61 | with: 62 | upload_url: ${{ needs.create-release.outputs.upload_url }} 63 | asset_path: tftp-now-${{ matrix.os }}-${{ matrix.GOARCH }}${{ matrix.os == 'windows' && '.exe' || '' }} 64 | asset_name: tftp-now-${{ matrix.os }}-${{ matrix.GOARCH }}${{ matrix.os == 'windows' && '.exe' || '' }} 65 | asset_content_type: application/octet-stream 66 | 67 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | ** tftp-now ** 2 | 3 | MIT License 4 | 5 | Copyright (c) 2023 Takumi Sueda 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. 24 | 25 | 26 | ** Portions from github.com/pin/tftp ** 27 | 28 | The MIT License (MIT) 29 | Copyright (c) 2016 Dmitri Popov 30 | 31 | Permission is hereby granted, free of charge, to any person obtaining 32 | a copy of this software and associated documentation files (the 33 | "Software"), to deal in the Software without restriction, including 34 | without limitation the rights to use, copy, modify, merge, publish, 35 | distribute, sublicense, and/or sell copies of the Software, and to 36 | permit persons to whom the Software is furnished to do so, subject to 37 | the following conditions: 38 | 39 | The above copyright notice and this permission notice shall be 40 | included in all copies or substantial portions of the Software. 41 | 42 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, 43 | EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF 44 | MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 45 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE 46 | LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION 47 | OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION 48 | WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "errors" 5 | "io" 6 | "os" 7 | 8 | "github.com/oklog/ulid/v2" 9 | "github.com/rs/zerolog/log" 10 | ) 11 | 12 | var root string 13 | 14 | func SetRoot(p string) { 15 | root = p 16 | } 17 | 18 | // ReadHandler is called when client starts file download from server 19 | func ReadHandler(requestedPath string, rf io.ReaderFrom) error { 20 | reqID := ulid.Make().String() 21 | log.Info().Str("requestId", reqID).Msgf("read request: %s", requestedPath) 22 | 23 | evalPath, err := evaluatePath(requestedPath, root, true) 24 | if err != nil { 25 | perr := InvalidPathError{} 26 | if errors.As(err, &perr) { 27 | log.Error().Str("requestId", reqID).EmbedObject(perr).Msgf("failed to evaluate path") 28 | } else { 29 | log.Error().Str("requestId", reqID).Msgf("failed to evaluate path: %s", err) 30 | } 31 | return err 32 | } 33 | 34 | log.Debug().Str("requestId", reqID).Msgf("evaluated path: %s", evalPath) 35 | 36 | file, err := os.Open(evalPath) 37 | if err != nil { 38 | log.Error().Str("requestId", reqID).Msgf("failed to open the file: %s", err) 39 | return err 40 | } 41 | defer file.Close() 42 | 43 | n, err := rf.ReadFrom(file) 44 | if err != nil { 45 | log.Error().Str("requestId", reqID).Msgf("failed to read from the file: %s", err) 46 | return err 47 | } 48 | log.Info().Str("requestId", reqID).Int64("bytes", n).Msg("successfully handled") 49 | return nil 50 | } 51 | 52 | // WriteHandler is called when client starts file upload to server 53 | func WriteHandler(requestedPath string, wt io.WriterTo) error { 54 | reqID := ulid.Make().String() 55 | log.Info().Str("requestId", reqID).Msgf("write request: %s", requestedPath) 56 | 57 | evalPath, err := evaluatePath(requestedPath, root, false) 58 | if err != nil { 59 | perr := InvalidPathError{} 60 | if errors.As(err, &perr) { 61 | log.Error().Str("requestId", reqID).EmbedObject(perr).Msgf("failed to evaluate path") 62 | } else { 63 | log.Error().Str("requestId", reqID).Msgf("failed to evaluate path: %s", err) 64 | } 65 | return err 66 | } 67 | 68 | log.Debug().Str("requestId", reqID).Msgf("evaluated path: %s", evalPath) 69 | 70 | file, err := os.OpenFile(evalPath, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0644) 71 | if err != nil { 72 | log.Error().Str("requestId", reqID).Msgf("failed to open the file: %s", err) 73 | return err 74 | } 75 | defer file.Close() 76 | 77 | n, err := wt.WriteTo(file) 78 | if err != nil { 79 | log.Error().Str("requestId", reqID).Msgf("failed to write to the file: %s", err) 80 | return err 81 | } 82 | log.Info().Str("requestId", reqID).Int64("bytes", n).Msg("successfully handled") 83 | return nil 84 | } 85 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 2 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 3 | github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= 4 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 5 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 6 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 7 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 8 | github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= 9 | github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 10 | github.com/oklog/ulid/v2 v2.1.0 h1:+9lhoxAP56we25tyYETBBY1YLA2SaoLvUFgrP2miPJU= 11 | github.com/oklog/ulid/v2 v2.1.0/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= 12 | github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= 13 | github.com/pin/tftp/v3 v3.0.0 h1:o9cQpmWBSbgiaYXuN+qJAB12XBIv4dT7OuOONucn2l0= 14 | github.com/pin/tftp/v3 v3.0.0/go.mod h1:xwQaN4viYL019tM4i8iecm++5cGxSqen6AJEOEyEI0w= 15 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 16 | github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 17 | github.com/rs/zerolog v1.29.0 h1:Zes4hju04hjbvkVkOhdl2HpZa+0PmVwigmo8XoORE5w= 18 | github.com/rs/zerolog v1.29.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= 19 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 20 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 21 | golang.org/x/net v0.8.0 h1:Zrh2ngAOFYneWTAIAPethzeaQLuHwhuBkuV6ZiRnUaQ= 22 | golang.org/x/net v0.8.0/go.mod h1:QVkue5JL9kW//ek3r6jTKnTFis1tRmNAW2P1shuFdJc= 23 | golang.org/x/sync v0.1.0 h1:wsuoTGHzEhffawBOhz5CYhcrV4IdKZbEyZjBMuTp12o= 24 | golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 25 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 26 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 27 | golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 28 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 29 | golang.org/x/sys v0.6.0 h1:MVltZSvRTcU2ljQOhs94SXPftV6DCNnZViHeQps87pQ= 30 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 31 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 32 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by https://www.toptal.com/developers/gitignore/api/goland+all,macos 2 | # Edit at https://www.toptal.com/developers/gitignore?templates=goland+all,macos 3 | 4 | ### GoLand+all ### 5 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio, WebStorm and Rider 6 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 7 | 8 | # User-specific stuff 9 | .idea/**/workspace.xml 10 | .idea/**/tasks.xml 11 | .idea/**/usage.statistics.xml 12 | .idea/**/dictionaries 13 | .idea/**/shelf 14 | 15 | # AWS User-specific 16 | .idea/**/aws.xml 17 | 18 | # Generated files 19 | .idea/**/contentModel.xml 20 | 21 | # Sensitive or high-churn files 22 | .idea/**/dataSources/ 23 | .idea/**/dataSources.ids 24 | .idea/**/dataSources.local.xml 25 | .idea/**/sqlDataSources.xml 26 | .idea/**/dynamic.xml 27 | .idea/**/uiDesigner.xml 28 | .idea/**/dbnavigator.xml 29 | 30 | # Gradle 31 | .idea/**/gradle.xml 32 | .idea/**/libraries 33 | 34 | # Gradle and Maven with auto-import 35 | # When using Gradle or Maven with auto-import, you should exclude module files, 36 | # since they will be recreated, and may cause churn. Uncomment if using 37 | # auto-import. 38 | # .idea/artifacts 39 | # .idea/compiler.xml 40 | # .idea/jarRepositories.xml 41 | # .idea/modules.xml 42 | # .idea/*.iml 43 | # .idea/modules 44 | # *.iml 45 | # *.ipr 46 | 47 | # CMake 48 | cmake-build-*/ 49 | 50 | # Mongo Explorer plugin 51 | .idea/**/mongoSettings.xml 52 | 53 | # File-based project format 54 | *.iws 55 | 56 | # IntelliJ 57 | out/ 58 | 59 | # mpeltonen/sbt-idea plugin 60 | .idea_modules/ 61 | 62 | # JIRA plugin 63 | atlassian-ide-plugin.xml 64 | 65 | # Cursive Clojure plugin 66 | .idea/replstate.xml 67 | 68 | # SonarLint plugin 69 | .idea/sonarlint/ 70 | 71 | # Crashlytics plugin (for Android Studio and IntelliJ) 72 | com_crashlytics_export_strings.xml 73 | crashlytics.properties 74 | crashlytics-build.properties 75 | fabric.properties 76 | 77 | # Editor-based Rest Client 78 | .idea/httpRequests 79 | 80 | # Android studio 3.1+ serialized cache file 81 | .idea/caches/build_file_checksums.ser 82 | 83 | ### GoLand+all Patch ### 84 | # Ignore everything but code style settings and run configurations 85 | # that are supposed to be shared within teams. 86 | 87 | .idea/* 88 | 89 | !.idea/codeStyles 90 | !.idea/runConfigurations 91 | 92 | ### macOS ### 93 | # General 94 | .DS_Store 95 | .AppleDouble 96 | .LSOverride 97 | 98 | # Icon must end with two \r 99 | Icon 100 | 101 | # Thumbnails 102 | ._* 103 | 104 | # Files that might appear in the root of a volume 105 | .DocumentRevisions-V100 106 | .fseventsd 107 | .Spotlight-V100 108 | .TemporaryItems 109 | .Trashes 110 | .VolumeIcon.icns 111 | .com.apple.timemachine.donotpresent 112 | 113 | # Directories potentially created on remote AFP share 114 | .AppleDB 115 | .AppleDesktop 116 | Network Trash Folder 117 | Temporary Items 118 | .apdisk 119 | 120 | ### macOS Patch ### 121 | # iCloud generated files 122 | *.icloud 123 | 124 | # End of https://www.toptal.com/developers/gitignore/api/goland+all,macos 125 | 126 | # go build 127 | tftp-now 128 | -------------------------------------------------------------------------------- /integration_test.go: -------------------------------------------------------------------------------- 1 | package main_test 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "net" 7 | "os" 8 | "testing" 9 | 10 | "github.com/pin/tftp/v3" 11 | "github.com/puhitaku/tftp-now/server" 12 | "github.com/rs/zerolog" 13 | "github.com/rs/zerolog/log" 14 | "golang.org/x/sync/errgroup" 15 | ) 16 | 17 | var _ = func() any { 18 | log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout}) 19 | os.Remove("testfile") 20 | return nil 21 | }() 22 | 23 | func TestWriteRead(t *testing.T) { 24 | eg := errgroup.Group{} 25 | svr := tftp.NewServer(server.ReadHandler, server.WriteHandler) 26 | addr := "localhost:10069" 27 | 28 | a, err := net.ResolveUDPAddr("udp", addr) 29 | if err != nil { 30 | t.Fatalf("failed to resolve addr: %s", err) 31 | } 32 | conn, err := net.ListenUDP("udp", a) 33 | if err != nil { 34 | t.Fatalf("failed to listen: %s", err) 35 | } 36 | 37 | eg.Go(func() error { 38 | return svr.Serve(conn) 39 | }) 40 | 41 | cli, err := tftp.NewClient(addr) 42 | if err != nil { 43 | t.Fatalf("failed to create a client: %s", err) 44 | } 45 | 46 | defer os.Remove("testfile") 47 | 48 | const ( 49 | blockLenDefault = 512 50 | blockLenFitMTU = 1428 // From RFC 2348. MTU 1500 - 72 51 | blockLenJumbo = 8928 // Jumbo frame. MTU 9000 - 72 52 | ) 53 | 54 | conditions := []struct { 55 | Name string 56 | ClientBlockLen int 57 | ServerBlockLen int 58 | }{ 59 | {Name: "C=default,S=default", ClientBlockLen: blockLenDefault, ServerBlockLen: blockLenDefault}, 60 | {Name: "C=default,S=fit MTU", ClientBlockLen: blockLenDefault, ServerBlockLen: blockLenFitMTU}, 61 | {Name: "C=default,S=jumbo", ClientBlockLen: blockLenDefault, ServerBlockLen: blockLenJumbo}, 62 | {Name: "C=fit MTU,S=default", ClientBlockLen: blockLenFitMTU, ServerBlockLen: blockLenDefault}, 63 | {Name: "C=fit MTU,S=fit MTU", ClientBlockLen: blockLenFitMTU, ServerBlockLen: blockLenFitMTU}, 64 | {Name: "C=fit MTU,S=jumbo", ClientBlockLen: blockLenFitMTU, ServerBlockLen: blockLenJumbo}, 65 | {Name: "C=jumbo,S=default", ClientBlockLen: blockLenJumbo, ServerBlockLen: blockLenDefault}, 66 | {Name: "C=jumbo,S=fit MTU", ClientBlockLen: blockLenJumbo, ServerBlockLen: blockLenFitMTU}, 67 | {Name: "C=jumbo,S=jumbo", ClientBlockLen: blockLenJumbo, ServerBlockLen: blockLenJumbo}, 68 | } 69 | 70 | for _, condition := range conditions { 71 | t.Run(condition.Name, func(t *testing.T) { 72 | svr.SetBlockSize(condition.ServerBlockLen) 73 | cli.SetBlockSize(condition.ClientBlockLen) 74 | 75 | rf, err := cli.Send("testfile", "octet") 76 | if err != nil { 77 | t.Fatalf("failed to start sending: %s", err) 78 | } 79 | 80 | body := make([]byte, 65536) // Covers the TFTP's block size max limit 65464 81 | _, _ = rand.Read(body) 82 | _, err = rf.ReadFrom(bytes.NewReader(body)) 83 | if err != nil { 84 | t.Fatalf("failed to send: %s", err) 85 | } 86 | defer os.Remove("testfile") 87 | 88 | wt, err := cli.Receive("testfile", "octet") 89 | if err != nil { 90 | t.Fatalf("failed to start receiving: %s", err) 91 | } 92 | 93 | writeBuf := bytes.NewBuffer(nil) 94 | _, err = wt.WriteTo(writeBuf) 95 | if err != nil { 96 | t.Fatalf("failed to receive: %s", err) 97 | } 98 | 99 | if b := writeBuf.Bytes(); !bytes.Equal(body, b) { 100 | t.Errorf("received data differ, expect: %+v actual: %+v", body, b) 101 | } 102 | }) 103 | } 104 | 105 | svr.Shutdown() 106 | err = eg.Wait() 107 | if err != nil { 108 | t.Fatalf("server returned an error: %s", err) 109 | } 110 | } 111 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # tftp-now 2 | 3 | *Single-binary TFTP server and client that you can use right now. No package installation, no configuration, no frustration.* 4 | 5 | 6 | # tl;dr 7 | 8 | 1. Download the latest executable. 9 | 10 | - Linux [amd64](https://github.com/puhitaku/tftp-now/releases/latest/download/tftp-now-linux-amd64) / 11 | [arm](https://github.com/puhitaku/tftp-now/releases/latest/download/tftp-now-linux-arm) / 12 | [arm64](https://github.com/puhitaku/tftp-now/releases/latest/download/tftp-now-linux-arm64) / 13 | [riscv64](https://github.com/puhitaku/tftp-now/releases/latest/download/tftp-now-linux-riscv64) / 14 | [mipsle](https://github.com/puhitaku/tftp-now/releases/latest/download/tftp-now-linux-mipsle) / 15 | [mips64le](https://github.com/puhitaku/tftp-now/releases/latest/download/tftp-now-linux-mips64le) 16 | - macOS [amd64](https://github.com/puhitaku/tftp-now/releases/latest/download/tftp-now-darwin-amd64) / 17 | [arm64](https://github.com/puhitaku/tftp-now/releases/latest/download/tftp-now-darwin-arm64) 18 | - Windows [amd64](https://github.com/puhitaku/tftp-now/releases/latest/download/tftp-now-windows-amd64.exe) / 19 | [arm64](https://github.com/puhitaku/tftp-now/releases/latest/download/tftp-now-windows-arm64.exe) 20 | 21 | 1. Make it executable. 22 | 23 | ``` 24 | $ chmod +x tftp-now-darwin-arm64 # example for macOS 25 | ``` 26 | 27 | 1. Run it. 28 | 29 | 1. As a server: `tftp-now-{OS}-{ARCH} serve` 30 | 1. As a client, to read (receive): `tftp-now-{OS}-{ARCH} read -remote remote/path/to/read.bin -local read.bin` 31 | 1. As a client, to write (send): `tftp-now-{OS}-{ARCH} write -remote remote/path/to/write.bin -local write.bin` 32 | 33 | 34 | # Download & Run 35 | 36 | Download the latest executable from [the release page](https://github.com/puhitaku/tftp-now/releases/latest). If there's no binary that runs on your system, please raise an issue. 37 | 38 | ``` 39 | $ tftp-now 40 | tftp-now 1.2.0 41 | 42 | Usage of tftp-now: 43 | tftp-now [] 44 | 45 | Server Commands: 46 | serve Start TFTP server 47 | 48 | Client Commands: 49 | read Read a file from a TFTP server 50 | write Write a file to a TFTP server 51 | 52 | Other Commands: 53 | help Show this help 54 | 55 | 56 | Example (serve): start serving on 0.0.0.0:69 57 | $ tftp-now serve 58 | 59 | Example (read): receive '{server root}/dir/foo' from 192.168.1.1 and save it to 'bar'. 60 | $ tftp-now read -host 192.168.1.1 -remote dir/foo -local bar 61 | 62 | Example (write): send 'bar' to '{server root}/dir/foo' of 192.168.1.1. 63 | $ tftp-now write -host 192.168.1.1 -remote dir/foo -local bar 64 | 65 | 66 | Tips: 67 | - If tftp-now executable itself or a link to tftp-now is named "tftp-now-serve", 68 | tftp-now will start a TFTP server without any explicit subcommand. Please specify 69 | a subcommand if you want to specify options. 70 | - The block size for the server will be clamped to the smaller of the block size 71 | a client requests and the MTU (minus overhead) of the interface. 72 | ``` 73 | 74 | 75 | # Why 76 | 77 | I enjoy installing OpenWrt onto routers, but one of the main challenges is transferring it to the bootloader via TFTP. To do this, a temporary TFTP server or client is necessary, but I have always struggled with setting up a TFTP server. 78 | 79 | While macOS has an out-of-the-box TFTP server, it requires running launchctl to invoke the hidden server. The process is tricky, and I always Google for guidance. 80 | 81 | Linux distros, on the other hand, usually don't have a built-in TFTP server. Installing tftpd via apt is an option, but it's configured for inetd by default and requires some additional configuration. Only the manpage and Google can provide guidance on how to do it properly. 82 | 83 | As for Windows, it doesn't come with a TFTP server by default, except for the server variants. Community-based software is the first choice for Windows, and again, Google is the go-to source for finding the right software to download. 84 | 85 | It's frustrating that setting up a TFTP server is always such a hassle. This is why I created tftp-now. 86 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | _ "embed" 5 | "flag" 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "time" 10 | 11 | "github.com/pin/tftp/v3" 12 | "github.com/puhitaku/tftp-now/server" 13 | "github.com/rs/zerolog" 14 | "github.com/rs/zerolog/log" 15 | ) 16 | 17 | //go:embed VERSION 18 | var version string 19 | 20 | var usage = fmt.Sprintf(` 21 | tftp-now %s 22 | 23 | Usage of tftp-now: 24 | tftp-now [] 25 | 26 | Server Commands: 27 | serve Start TFTP server 28 | 29 | Client Commands: 30 | read Read a file from a TFTP server 31 | write Write a file to a TFTP server 32 | 33 | Other Commands: 34 | help Show this help 35 | 36 | 37 | Example (serve): start serving on 0.0.0.0:69 38 | $ tftp-now serve 39 | 40 | Example (read): receive '{server root}/dir/foo' from 192.168.1.1 and save it to 'bar'. 41 | $ tftp-now read -host 192.168.1.1 -remote dir/foo -local bar 42 | 43 | Example (write): send 'bar' to '{server root}/dir/foo' of 192.168.1.1. 44 | $ tftp-now write -host 192.168.1.1 -remote dir/foo -local bar 45 | 46 | 47 | Tips: 48 | - If tftp-now executable itself or a link to tftp-now is named "tftp-now-serve", 49 | tftp-now will start a TFTP server without any explicit subcommand. Please specify 50 | a subcommand if you want to specify options. 51 | - The block size for the server will be clamped to the smaller of the block size 52 | a client requests and the MTU (minus overhead) of the interface. 53 | `, version)[1:] 54 | 55 | func main() { 56 | os.Exit(main_()) 57 | } 58 | 59 | func main_() int { 60 | log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stdout}).Level(zerolog.InfoLevel) 61 | 62 | const ( 63 | serve = "serve" 64 | read = "read" 65 | write = "write" 66 | help = "help" 67 | ) 68 | 69 | command := help 70 | options := []string{} 71 | 72 | if len(os.Args) > 1 { 73 | command = os.Args[1] 74 | options = os.Args[2:] 75 | } else if filepath.Base(os.Args[0]) == "tftp-now-serve" { 76 | log.Info().Msgf("tftp-now will start a server since the executable's name is 'tftp-now-serve'") 77 | command = serve 78 | } 79 | 80 | // validateBlockSize validates the block size. 81 | // While RFC 2348 defines that block size must be between 8 and 65464, 82 | // pin/tftp will not accept and silently ignores the length less than 512 Bytes. 83 | validateBlockSize := func(l int) (valid bool) { 84 | valid = 512 <= l && l <= 65464 85 | if !valid { 86 | log.Error().Msgf("block size must be between 512 and 65464") 87 | } 88 | return 89 | } 90 | 91 | switch command { 92 | case serve: 93 | serverCmd := flag.NewFlagSet("tftp-now serve []", flag.ExitOnError) 94 | host := serverCmd.String("host", "0.0.0.0", "Host address") 95 | port := serverCmd.Int("port", 69, "Port number") 96 | root := serverCmd.String("root", ".", "Root directory path") 97 | blkSize := serverCmd.Int("blksize", 512, "Block size") 98 | verbose := serverCmd.Bool("verbose", false, "Enable verbose debug output") 99 | 100 | err := serverCmd.Parse(options) 101 | if err != nil { 102 | log.Error().Msgf("failed to parse args: %s", err) 103 | return 1 104 | } 105 | 106 | if !validateBlockSize(*blkSize) { 107 | return 1 108 | } 109 | 110 | if *verbose { 111 | log.Logger = log.Logger.Level(zerolog.DebugLevel) 112 | } 113 | 114 | abs, err := filepath.Abs(*root) 115 | if err != nil { 116 | log.Error().Msgf("failed to get the absolute path: %s", err) 117 | return 1 118 | } 119 | 120 | server.SetRoot(abs) 121 | s := tftp.NewServer(server.ReadHandler, server.WriteHandler) 122 | s.SetBlockSize(*blkSize) 123 | s.SetTimeout(5 * time.Second) 124 | 125 | log.Info().Str("host", *host).Int("port", *port).Str("directory", abs).Int("blocksize", *blkSize).Msg("starting the TFTP server") 126 | err = s.ListenAndServe(fmt.Sprintf("%s:%d", *host, *port)) 127 | if err != nil { 128 | log.Error().Msgf("failed to run the server: %s", err) 129 | return 1 130 | } 131 | case read: 132 | clientCmd := flag.NewFlagSet("tftp-now read []", flag.ExitOnError) 133 | host := clientCmd.String("host", "127.0.0.1", "Host address") 134 | port := clientCmd.Int("port", 69, "Port number") 135 | remote := clientCmd.String("remote", "", "Remote file path to read from (REQUIRED)") 136 | local := clientCmd.String("local", "", "Local file path to save to (if unspecified, inferred from -remote)") 137 | blkSize := clientCmd.Int("blksize", 512, "Block size") 138 | 139 | if len(options) < 2 { 140 | clientCmd.Usage() 141 | return 1 142 | } 143 | 144 | err := clientCmd.Parse(options) 145 | if err != nil { 146 | log.Error().Msgf("failed to parse args: %s", err) 147 | return 1 148 | } 149 | 150 | if *remote == "" { 151 | log.Error().Msgf("please specify '-remote'") 152 | return 1 153 | } 154 | 155 | if *local == "" { 156 | *local = filepath.Base(*remote) 157 | } 158 | 159 | if !validateBlockSize(*blkSize) { 160 | return 1 161 | } 162 | 163 | log.Info().Str("host", fmt.Sprintf("%s:%d", *host, *port)).Str("remote", *remote).Str("local", *local).Int("blocksize", *blkSize).Msgf("start reading") 164 | 165 | cli, err := tftp.NewClient(fmt.Sprintf("%s:%d", *host, *port)) 166 | if err != nil { 167 | log.Error().Msgf("failed to create a new client: %s", err) 168 | return 1 169 | } 170 | 171 | cli.SetBlockSize(*blkSize) 172 | 173 | tf, err := cli.Receive(*remote, "octet") 174 | if err != nil { 175 | log.Error().Msgf("failed to receive '%s': %s", *remote, err) 176 | return 1 177 | } 178 | 179 | file, err := os.Create(*local) 180 | if err != nil { 181 | log.Error().Msg(err.Error()) 182 | return 1 183 | } 184 | defer file.Close() 185 | 186 | n, err := tf.WriteTo(file) 187 | if err != nil { 188 | log.Error().Msgf("failed to write the received data to '%s': %s", *local, err) 189 | return 1 190 | } 191 | 192 | log.Info().Int64("length", n).Msgf("successfully received") 193 | case write: 194 | clientCmd := flag.NewFlagSet("tftp-now write []", flag.ExitOnError) 195 | host := clientCmd.String("host", "127.0.0.1", "Host address") 196 | port := clientCmd.Int("port", 69, "Port number") 197 | remote := clientCmd.String("remote", "", "Remote file path to save to (REQUIRED)") 198 | local := clientCmd.String("local", "", "Local file path to read from (REQUIRED)") 199 | blkSize := clientCmd.Int("blksize", 512, "Block size") 200 | 201 | if len(options) < 2 { 202 | clientCmd.Usage() 203 | return 1 204 | } 205 | 206 | err := clientCmd.Parse(options) 207 | if err != nil { 208 | log.Error().Msgf("failed to parse args: %s", err) 209 | return 1 210 | } 211 | 212 | if *remote == "" { 213 | log.Error().Msgf("please specify '-remote'") 214 | return 1 215 | } else if *local == "" { 216 | log.Error().Msgf("please specify '-local'") 217 | return 1 218 | } 219 | 220 | file, err := os.Open(*local) 221 | if err != nil { 222 | log.Error().Msg(err.Error()) 223 | return 1 224 | } 225 | defer file.Close() 226 | 227 | if !validateBlockSize(*blkSize) { 228 | return 1 229 | } 230 | 231 | log.Info().Str("host", fmt.Sprintf("%s:%d", *host, *port)).Str("remote", *remote).Str("local", *local).Int("blocksize", *blkSize).Msgf("start writing") 232 | 233 | cli, err := tftp.NewClient(fmt.Sprintf("%s:%d", *host, *port)) 234 | if err != nil { 235 | log.Error().Msgf("failed to create a new client: %s", err) 236 | return 1 237 | } 238 | 239 | cli.SetBlockSize(*blkSize) 240 | 241 | rf, err := cli.Send(*remote, "octet") 242 | if err != nil { 243 | log.Error().Msgf("failed to send '%s': %s", *remote, err) 244 | return 1 245 | } 246 | 247 | n, err := rf.ReadFrom(file) 248 | if err != nil { 249 | log.Error().Msgf("failed to read the sending data from '%s': %s", *local, err) 250 | return 1 251 | } 252 | 253 | log.Info().Int64("length", n).Msgf("successfully sent") 254 | case help: 255 | fmt.Print(usage) 256 | return 1 257 | default: 258 | fmt.Println("Invalid command. Specify 'serve', 'read', 'write', or 'help'.") 259 | return 1 260 | } 261 | 262 | return 0 263 | } 264 | --------------------------------------------------------------------------------