├── .github ├── ISSUE_TEMPLATE │ ├── bug_report.md │ └── feature_request.md └── workflows │ └── integration_testing.yaml ├── .gitignore ├── .goreleaser.yaml ├── LICENSE ├── Makefile ├── README.md ├── cmd ├── app.go ├── conf │ ├── cgi.go │ ├── configuration.go │ ├── credentials.go │ ├── download.go │ ├── flags.go │ ├── redirect.go │ ├── server.go │ ├── tls.go │ ├── upload.go │ └── windows.go ├── mdns.go └── run.go ├── doc ├── man │ └── main.go └── md │ ├── 0_video.md │ ├── 1_installation.md │ ├── 2_goneshot.md │ ├── 3_examples.md │ ├── 4_bugs_contributing.md │ └── main.go ├── go.mod ├── go.sum ├── go.work ├── go.work.sum ├── icon └── icon.svg ├── install.sh ├── integrations └── emacs │ └── oneshot.el ├── internal ├── file │ ├── reader.go │ ├── tar.go │ ├── writer.go │ └── zip.go ├── handlers │ ├── authenticate.go │ ├── bots.go │ ├── cgi.go │ ├── download.go │ ├── redirect.go │ └── upload.go └── server │ ├── add-route.go │ ├── icon.go │ ├── icon.png │ ├── route.go │ └── server.go ├── main.go ├── oneshot.1 ├── oneshot_banner.png ├── release ├── v2 ├── .gitignore ├── .gon │ ├── amd64.hcl │ └── arm64.hcl ├── .goreleaser.yaml ├── LICENSE ├── Makefile ├── README.md ├── browser │ ├── upload-client │ │ ├── build.mjs │ │ ├── main.ts │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── src │ │ │ ├── sendFormData.ts │ │ │ └── sendString.ts │ │ └── tsconfig.json │ └── webrtc-client │ │ ├── build.mjs │ │ ├── main.ts │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── src │ │ ├── browser │ │ │ ├── activateScriptTags.ts │ │ │ ├── triggerDownload.ts │ │ │ └── visit.ts │ │ ├── fetch │ │ │ ├── constants.ts │ │ │ ├── factory.ts │ │ │ ├── writeBody.ts │ │ │ └── writeHeader.ts │ │ ├── types.ts │ │ ├── util.ts │ │ └── webrtcClient.ts │ │ └── tsconfig.json ├── build-tools │ └── man │ │ └── main.go ├── cmd │ └── main.go ├── go.mod ├── go.sum ├── install.sh ├── integration_testing │ ├── exec │ │ └── exec_test.go │ ├── oneshot.go │ ├── receive │ │ └── receive_test.go │ ├── redirect │ │ └── redirect_test.go │ ├── reverse-proxy │ │ └── rproxy_test.go │ ├── root │ │ └── root_test.go │ ├── send │ │ └── send_test.go │ ├── testing.go │ └── version │ │ └── version_test.go ├── pkg │ ├── cgi │ │ ├── cgi.go │ │ ├── env.go │ │ ├── exec.go │ │ ├── isExec_unix.go │ │ ├── isExec_windows.go │ │ └── outputHandler.go │ ├── commands │ │ ├── closers.go │ │ ├── config │ │ │ ├── config.go │ │ │ ├── get │ │ │ │ ├── cobra.go │ │ │ │ └── usage.go │ │ │ ├── path │ │ │ │ ├── cobra.go │ │ │ │ └── usage.go │ │ │ ├── set │ │ │ │ ├── cobra.go │ │ │ │ └── usage.go │ │ │ └── usage.go │ │ ├── discovery-server │ │ │ ├── cobra.go │ │ │ ├── configuration │ │ │ │ └── configuration.go │ │ │ ├── handleHTTP.go │ │ │ ├── oneshotServer.go │ │ │ ├── server.go │ │ │ ├── template │ │ │ │ ├── sd-streams-polyfill.min.js │ │ │ │ ├── template.go │ │ │ │ ├── templates │ │ │ │ │ ├── auto-answer.html │ │ │ │ │ ├── error.html │ │ │ │ │ ├── index.html │ │ │ │ │ └── manual-answer.html │ │ │ │ └── webrtc-client.js │ │ │ └── usage.go │ │ ├── exec │ │ │ ├── cobra.go │ │ │ ├── configuration │ │ │ │ └── configuration.go │ │ │ └── usage.go │ │ ├── httpHandler.go │ │ ├── p2p │ │ │ ├── browser-client │ │ │ │ ├── cobra.go │ │ │ │ ├── configuration │ │ │ │ │ └── configuration.go │ │ │ │ └── usage.go │ │ │ ├── client │ │ │ │ ├── client.go │ │ │ │ ├── configuration │ │ │ │ │ └── configuration.go │ │ │ │ ├── discovery │ │ │ │ │ └── discoveryServerNegotiation.go │ │ │ │ ├── receive │ │ │ │ │ ├── cobra.go │ │ │ │ │ ├── configuration │ │ │ │ │ │ └── configuration.go │ │ │ │ │ └── usage.go │ │ │ │ ├── send │ │ │ │ │ ├── cobra.go │ │ │ │ │ ├── configuration │ │ │ │ │ │ └── configuration.go │ │ │ │ │ └── usage.go │ │ │ │ └── usage.go │ │ │ ├── configuration │ │ │ │ └── configuration.go │ │ │ ├── p2p.go │ │ │ └── usage.go │ │ ├── receive │ │ │ ├── cobra.go │ │ │ ├── configuration │ │ │ │ └── configuration.go │ │ │ ├── index.template.html │ │ │ ├── main.js │ │ │ ├── serve.go │ │ │ ├── usage.go │ │ │ ├── util.go │ │ │ └── webrtc-client.js │ │ ├── redirect │ │ │ ├── cobra.go │ │ │ ├── configuration │ │ │ │ └── configuration.go │ │ │ └── usage.go │ │ ├── root │ │ │ ├── configureServer.go │ │ │ ├── discoveryServer.go │ │ │ ├── entry.go │ │ │ ├── help.go │ │ │ ├── listenWebRTC.go │ │ │ ├── portMapping.go │ │ │ ├── runServer.go │ │ │ └── usage.go │ │ ├── rproxy │ │ │ ├── cobra.go │ │ │ ├── configuration │ │ │ │ └── configuration.go │ │ │ └── usage.go │ │ ├── send │ │ │ ├── cobra.go │ │ │ ├── configuration │ │ │ │ └── configuration.go │ │ │ ├── serve.go │ │ │ └── usage.go │ │ └── version │ │ │ ├── cobra.go │ │ │ └── usage.go │ ├── configuration │ │ ├── basicAuth.go │ │ ├── configuration.go │ │ ├── cors.go │ │ ├── discovery.go │ │ ├── natTraversal.go │ │ ├── output.go │ │ ├── root.go │ │ ├── server.go │ │ └── util.go │ ├── events │ │ ├── events.go │ │ ├── exitCodes.go │ │ ├── file.go │ │ └── httpRequest.go │ ├── file │ │ ├── isDirWritable_unix.go │ │ ├── isDirWritable_windows.go │ │ ├── reader.go │ │ ├── tar.go │ │ ├── writer.go │ │ └── zip.go │ ├── flagargs │ │ └── flagargs.go │ ├── flags │ │ └── flags.go │ ├── log │ │ └── log.go │ ├── net │ │ ├── http │ │ │ ├── http.go │ │ │ ├── middleware.go │ │ │ └── server.go │ │ ├── listenerTimer.go │ │ ├── network.go │ │ ├── upnp-igd │ │ │ ├── device.go │ │ │ ├── discover.go │ │ │ ├── request.go │ │ │ ├── service.go │ │ │ └── upnp.go │ │ └── webrtc │ │ │ ├── client │ │ │ ├── flowControlledWriter.go │ │ │ ├── roundTrip.go │ │ │ └── transport.go │ │ │ ├── sdp │ │ │ ├── sdp.go │ │ │ └── signallers │ │ │ │ ├── fileClientSignaller.go │ │ │ │ ├── fileServerSignaller.go │ │ │ │ ├── serverClientSignaller.go │ │ │ │ ├── serverServerSignaller.go │ │ │ │ └── signaller.go │ │ │ ├── server │ │ │ ├── dataChannel.go │ │ │ ├── httpResponseWriter.go │ │ │ ├── peerConnection.go │ │ │ └── server.go │ │ │ ├── signallingserver │ │ │ ├── discoveryServer.go │ │ │ ├── headers │ │ │ │ └── headers.go │ │ │ ├── messages │ │ │ │ ├── messages.go │ │ │ │ └── unmarshal.go │ │ │ └── proto │ │ │ │ ├── generate.go │ │ │ │ ├── generate.sh │ │ │ │ ├── proto │ │ │ │ └── signallingServer.proto │ │ │ │ ├── signallingServer.pb.go │ │ │ │ └── signallingServer_grpc.pb.go │ │ │ └── webrtc.go │ ├── os │ │ └── os.go │ ├── output │ │ ├── api.go │ │ ├── codes │ │ │ └── ansi.go │ │ ├── fmt │ │ │ └── fmt.go │ │ ├── human.go │ │ ├── json.go │ │ ├── output.go │ │ ├── progress.go │ │ ├── quiet.go │ │ ├── responseWriter.go │ │ ├── setCommandInvocation.go │ │ └── spinner.go │ ├── sys │ │ └── sys.go │ └── version │ │ └── version.go └── version.txt └── version.txt /.github/ISSUE_TEMPLATE/bug_report.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Bug report 3 | about: Create a report to help oneshot improve 4 | title: '' 5 | labels: bug 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Describe the bug** 11 | A clear and concise description of what the bug is. 12 | 13 | **To Reproduce** 14 | Steps to reproduce the behavior: 15 | 1. Run '...' 16 | 2. Scroll down to '....' 17 | 3. See error 18 | 19 | **Expected behavior** 20 | A clear and concise description of what you expected to happen. 21 | 22 | **Output and error messages** 23 | If applicable, add any output or error messages given by oneshot. 24 | 25 | **Desktop (please complete the following information):** 26 | - OS: [e.g. macOS 10.15.6] 27 | - Version [e.g. v1.1.3] 28 | 29 | **Additional context** 30 | Add any other context about the problem here. 31 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature_request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature request 3 | about: Suggest an idea for oneshot 4 | title: '' 5 | labels: enhancement 6 | assignees: '' 7 | 8 | --- 9 | 10 | **Is your feature request related to a problem? Please describe.** 11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] 12 | 13 | **Describe the solution you'd like** 14 | A clear and concise description of what you want to happen. 15 | 16 | **Describe alternatives you've considered** 17 | A clear and concise description of any alternative solutions or features you've considered. 18 | 19 | **Additional context** 20 | Add any other context or screenshots about the feature request here. 21 | -------------------------------------------------------------------------------- /.github/workflows/integration_testing.yaml: -------------------------------------------------------------------------------- 1 | name: Integration Testing 2 | on: 3 | push: 4 | branches: 5 | - v2 6 | pull_request: 7 | branches: 8 | - v2 9 | workflow_dispatch: 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - name: Checkout code 15 | uses: actions/checkout@v2 16 | with: 17 | fetch-depth: 0 18 | - name: Install Go 19 | uses: actions/setup-go@v2 20 | with: 21 | go-version: '1.21' 22 | - name: Install Node.js 23 | uses: actions/setup-node@v2 24 | with: 25 | node-version: '18' 26 | - name: Install dependencies 27 | run: make dep 28 | working-directory: ./v2 29 | - name: Build 30 | run: make 31 | working-directory: ./v2 32 | - name: Run integration tests 33 | run: make itest 34 | working-directory: ./v2 35 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | oneshot 2 | dist 3 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | env_files: 2 | github_token: ~/.tokens/github 3 | 4 | before: 5 | hooks: 6 | - go mod download 7 | 8 | builds: 9 | - id: linux 10 | main: main.go 11 | binary: oneshot 12 | ldflags: 13 | - -X github.com/forestnode-io/oneshot/cmd.version={{.Version}} 14 | - -X github.com/forestnode-io/oneshot/cmd.date={{.Date}} 15 | goos: 16 | - linux 17 | goarch: 18 | - amd64 19 | - arm 20 | - arm64 21 | - 386 22 | 23 | - id: macos 24 | main: main.go 25 | binary: oneshot 26 | ldflags: 27 | - -X github.com/forestnode-io/oneshot/cmd.version={{.Version}} 28 | - -X github.com/forestnode-io/oneshot/cmd.date={{.Date}} 29 | goos: 30 | - darwin 31 | goarch: 32 | - amd64 33 | 34 | - id: windows 35 | main: main.go 36 | binary: oneshot 37 | ldflags: 38 | - -X github.com/forestnode-io/oneshot/cmd.version={{.Version}} 39 | - -X github.com/forestnode-io/oneshot/cmd.date={{.Date}} 40 | goos: 41 | - windows 42 | goarch: 43 | - amd64 44 | - 386 45 | 46 | 47 | archives: 48 | - id: brew 49 | name_template: "oneshot_{{ .Version }}.{{ .Os }}-{{ .Arch }}" 50 | builds: 51 | - macos 52 | - linux 53 | replacements: 54 | darwin: macos 55 | amd64: x86_64 56 | format: zip 57 | files: 58 | - LICENSE 59 | - README.md 60 | - oneshot.1 61 | 62 | - id: windows-zip 63 | name_template: "oneshot_{{ .Version }}.{{ .Os }}-{{ .Arch }}" 64 | builds: 65 | - windows 66 | replacements: 67 | 386: i386 68 | amd64: x86_64 69 | format: zip 70 | files: 71 | - LICENSE 72 | - README.md 73 | 74 | - id: binary 75 | name_template: "oneshot_{{ .Version }}.{{ .Os }}-{{ .Arch }}" 76 | builds: 77 | - linux 78 | - macos 79 | - windows 80 | replacements: 81 | darwin: macos 82 | 386: i386 83 | amd64: x86_64 84 | format: binary 85 | 86 | 87 | checksum: 88 | name_template: 'checksums.txt' 89 | 90 | 91 | snapshot: 92 | name_template: "{{ .Tag }}" 93 | 94 | 95 | brews: 96 | - name: oneshot 97 | ids: 98 | - brew 99 | tap: 100 | owner: forestnode-io 101 | name: brew 102 | homepage: "https://github.com/forestnode-io/oneshot" 103 | description: "A single fire HTTP server." 104 | 105 | 106 | nfpms: 107 | - package_name: oneshot 108 | file_name_template: "oneshot_{{ .Version }}.{{ .Os }}-{{ .Arch }}" 109 | builds: 110 | - linux 111 | replacements: 112 | 386: i386 113 | amd64: x86_64 114 | description: A first-come-first-serve single-fire HTTP server. Easily transfer files to and from your terminal and any browser. 115 | license: MIT 116 | formats: 117 | - deb 118 | - rpm 119 | 120 | 121 | release: 122 | ids: 123 | - brew 124 | - windows-zip 125 | github: 126 | owner: forestnode-io 127 | name: oneshot 128 | 129 | 130 | changelog: 131 | sort: asc 132 | filters: 133 | exclude: 134 | - '^docs:' 135 | - '^test:' 136 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Raphael Reyna 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | LOCATION=github.com/forestnode-io/oneshot 2 | VERSION=`git describe --tags --abbrev=0` 3 | VERSION_FLAG=$(LOCATION)/cmd.version=$(VERSION) 4 | DATE=`date +"%d-%B-%Y"` 5 | DATE_FLAG=$(LOCATION)/cmd.date="${DATE}" 6 | MANPATH=/usr/local/share/man 7 | PREFIX=/usr/local 8 | HERE=$(shell dirname $(realpath $(firstword $(MAKEFILE_LIST)))) 9 | 10 | oneshot: 11 | go build -ldflags "-X ${VERSION_FLAG} -X ${DATE_FLAG} -s -w" . 12 | 13 | README.md: 14 | cd doc/md && go run -ldflags "-X ${VERSION_FLAG} -X ${DATE_FLAG}" \ 15 | . > $(HERE)/README.md 16 | 17 | oneshot.1: 18 | go run -ldflags "-X ${VERSION_FLAG} -X ${DATE_FLAG}" \ 19 | ./doc/man/main.go > $(HERE)/oneshot.1 20 | 21 | install-man-page: oneshot.1 22 | mv oneshot.1 $(MANPATH)/man1 23 | mandb 24 | 25 | 26 | .PHONY: install 27 | install: oneshot 28 | mkdir -p $(DESTDIR)$(PREFIX)/bin 29 | cp $< $(DESTDIR)$(PREFIX)/bin/oneshot 30 | 31 | .PHONY: uninstall 32 | uninstall: 33 | rm -f $(DESTDIR)$(PREFIX)/bin/oneshot 34 | 35 | .PHONY: clean 36 | clean: 37 | rm -f oneshot 38 | 39 | 40 | -------------------------------------------------------------------------------- /cmd/conf/cgi.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | import ( 4 | "fmt" 5 | ezcgi "github.com/raphaelreyna/ez-cgi/pkg/cgi" 6 | "github.com/forestnode-io/oneshot/internal/handlers" 7 | "github.com/forestnode-io/oneshot/internal/server" 8 | "io" 9 | "math/rand" 10 | "net/http" 11 | "os" 12 | "strings" 13 | ) 14 | 15 | func (c *Conf) setupCGIRoute(args []string, srvr *server.Server) (*server.Route, error) { 16 | var err error 17 | 18 | handler := &ezcgi.Handler{ 19 | InheritEnv: c.EnvVars, 20 | } 21 | 22 | argLen := len(args) 23 | if argLen < 1 { 24 | if c.ShellCommand { 25 | return nil, fmt.Errorf("no shell command given\n exit") 26 | } 27 | return nil, fmt.Errorf("path to executable not given\n exit") 28 | } 29 | if c.ShellCommand { 30 | handler.Path = c.Shell 31 | handler.Args = []string{"-c", args[0]} 32 | } else { 33 | handler.Path = args[0] 34 | if argLen >= 2 { 35 | handler.Args = args[1:argLen] 36 | } 37 | } 38 | 39 | if c.CgiStderr != "" { 40 | handler.Stderr, err = os.Open(c.CgiStderr) 41 | defer handler.Stderr.(io.WriteCloser).Close() 42 | if err != nil { 43 | return nil, err 44 | } 45 | } 46 | 47 | header := http.Header{} 48 | for _, rh := range c.RawHeaders { 49 | parts := strings.SplitN(rh, ":", 2) 50 | if len(parts) < 2 { 51 | err = fmt.Errorf("invalid header: %s", rh) 52 | return nil, err 53 | } 54 | k := strings.TrimSpace(parts[0]) 55 | v := strings.TrimSpace(parts[1]) 56 | header.Set(k, v) 57 | } 58 | if c.FileMime != "" { 59 | header.Set("Content-Type", c.FileMime) 60 | } 61 | var fn string 62 | if c.FileName == "" { 63 | fn = fmt.Sprintf("%0-x", rand.Int31()) 64 | if c.FileExt != "" { 65 | fn += strings.ReplaceAll(c.FileExt, ".", "") 66 | } 67 | } else { 68 | fn = c.FileName 69 | } 70 | if !c.NoDownload { 71 | header.Set("Content-Disposition", 72 | fmt.Sprintf("attachment;filename=%s", fn)) 73 | } 74 | if len(header) != 0 { 75 | handler.Header = header 76 | } 77 | 78 | if c.Dir != "" { 79 | handler.Dir = c.Dir 80 | } else { 81 | handler.Dir, err = os.Getwd() 82 | if err != nil { 83 | return nil, err 84 | } 85 | } 86 | 87 | if !c.NoError { 88 | handler.Logger = srvr.ErrorLog 89 | } 90 | 91 | if c.ReplaceHeaders { 92 | handler.OutputHandler = ezcgi.EZOutputHandlerReplacer 93 | } 94 | 95 | if c.CgiStrict { 96 | handler.OutputHandler = ezcgi.DefaultOutputHandler 97 | } 98 | 99 | if handler.OutputHandler == nil { 100 | handler.OutputHandler = ezcgi.EZOutputHandler 101 | } 102 | 103 | route := &server.Route{ 104 | Pattern: "/", 105 | Methods: []string{"GET", "POST"}, 106 | DoneHandlerFunc: func(w http.ResponseWriter, r *http.Request) { 107 | w.WriteHeader(http.StatusGone) 108 | w.Write([]byte("gone")) 109 | }, 110 | } 111 | if c.ExitOnFail { 112 | route.MaxRequests = 1 113 | } else { 114 | route.MaxOK = 1 115 | } 116 | route.HandlerFunc = handlers.HandleCGI(handler, fn, c.FileMime, !c.AllowBots, srvr.InfoLog) 117 | 118 | return route, nil 119 | } 120 | -------------------------------------------------------------------------------- /cmd/conf/credentials.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | import ( 4 | "bufio" 5 | "io/ioutil" 6 | "math/rand" 7 | "os" 8 | "strings" 9 | ) 10 | 11 | func (c *Conf) SetupCredentials() error { 12 | if c.PasswordHidden { 13 | // Read password from standard in 14 | os.Stdout.WriteString("password: ") 15 | passreader := bufio.NewReader(os.Stdin) 16 | passwordBytes, err := passreader.ReadString('\n') 17 | if err != nil { 18 | return err 19 | } 20 | c.Password = string(passwordBytes) 21 | c.Password = strings.TrimSpace(c.Password) 22 | os.Stdout.WriteString("\n") 23 | } else if c.PasswordFile != "" { 24 | // Read password from file 25 | passwordBytes, err := ioutil.ReadFile(c.PasswordFile) 26 | if err != nil { 27 | return err 28 | } 29 | c.Password = string(passwordBytes) 30 | c.Password = strings.TrimSpace(c.Password) 31 | } 32 | 33 | if c.cmdFlagSet.Changed("username") && c.Username == "" { 34 | c.Username = randomUsername() 35 | c.randUser = true 36 | } 37 | if c.cmdFlagSet.Changed("password") && c.Password == "" { 38 | c.Password = randomPassword() 39 | c.randPass = true 40 | } 41 | 42 | return nil 43 | } 44 | 45 | func randomPassword() string { 46 | const lowerChars = "abcdefghijklmnopqrstuvwxyz" 47 | const upperChars = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" 48 | const numericChars = "1234567890" 49 | 50 | var defSeperator = "-" 51 | 52 | runes := []rune(lowerChars + upperChars + numericChars) 53 | l := len(runes) 54 | password := "" 55 | for i := 1; i < 15; i++ { 56 | if i%5 == 0 { 57 | password += defSeperator 58 | continue 59 | } 60 | password += string(runes[rand.Intn(l)]) 61 | } 62 | return password 63 | } 64 | 65 | func randomUsername() string { 66 | adjs := [...]string{"bulky", "fake", "artistic", "plush", "ornate", "kind", "nutty", "miniature", "huge", "evergreen", "several", "writhing", "scary", "equatorial", "obvious", "rich", "beneficial", "actual", "comfortable", "well-lit"} 67 | 68 | nouns := [...]string{"representative", "prompt", "respond", "safety", "blood", "fault", "lady", "routine", "position", "friend", "uncle", "savings", "ambition", "advice", "responsibility", "consist", "nobody", "film", "attitude", "heart"} 69 | 70 | l := len(adjs) 71 | 72 | return adjs[rand.Intn(l)] + "_" + nouns[rand.Intn(l)] 73 | } 74 | -------------------------------------------------------------------------------- /cmd/conf/download.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | 10 | "math/rand" 11 | 12 | "github.com/forestnode-io/oneshot/internal/file" 13 | "github.com/forestnode-io/oneshot/internal/handlers" 14 | "github.com/forestnode-io/oneshot/internal/server" 15 | ) 16 | 17 | func (c *Conf) setupDownloadRoute(args []string, srvr *server.Server) (*server.Route, error) { 18 | paths := args 19 | 20 | if len(paths) == 1 && c.FileName == "" { 21 | c.FileName = filepath.Base(paths[0]) 22 | 23 | } 24 | if c.ArchiveMethod != "zip" && c.ArchiveMethod != "tar.gz" { 25 | c.ArchiveMethod = "tar.gz" 26 | } 27 | 28 | if len(paths) == 0 && c.WaitForEOF { 29 | tdir, err := os.MkdirTemp("", "oneshot") 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | if c.FileName == "" { 35 | c.FileName = fmt.Sprintf("%0-x", rand.Int31()) 36 | } 37 | paths = append(paths, filepath.Join(tdir, c.FileName, c.FileExt)) 38 | c.stdinBufLoc = paths[0] 39 | } 40 | 41 | file := &file.FileReader{ 42 | Paths: paths, 43 | Name: c.FileName, 44 | Ext: c.FileExt, 45 | MimeType: c.FileMime, 46 | ArchiveMethod: c.ArchiveMethod, 47 | } 48 | 49 | if !c.NoInfo { 50 | file.ProgressWriter = os.Stdout 51 | } 52 | 53 | route := &server.Route{ 54 | Pattern: "/", 55 | Methods: []string{"GET"}, 56 | DoneHandlerFunc: func(w http.ResponseWriter, r *http.Request) { 57 | w.WriteHeader(http.StatusGone) 58 | w.Write([]byte("gone")) 59 | }, 60 | } 61 | if c.ExitOnFail { 62 | route.MaxRequests = 1 63 | } else { 64 | route.MaxOK = 1 65 | } 66 | 67 | header := http.Header{} 68 | for _, rh := range c.RawHeaders { 69 | parts := strings.SplitN(rh, ":", 2) 70 | if len(parts) < 2 { 71 | err := fmt.Errorf("invalid header: %s", rh) 72 | return nil, err 73 | } 74 | k := strings.TrimSpace(parts[0]) 75 | v := strings.TrimSpace(parts[1]) 76 | header.Set(k, v) 77 | } 78 | 79 | route.HandlerFunc = handlers.HandleDownload(file, !c.NoDownload, !c.AllowBots, header, srvr.InfoLog) 80 | 81 | return route, nil 82 | } 83 | -------------------------------------------------------------------------------- /cmd/conf/redirect.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | 8 | "github.com/forestnode-io/oneshot/internal/handlers" 9 | "github.com/forestnode-io/oneshot/internal/server" 10 | "net/url" 11 | ) 12 | 13 | func (c *Conf) setupRedirectRoute(args []string, srvr *server.Server) (*server.Route, error) { 14 | if len(args) == 0 { 15 | return nil, fmt.Errorf("missing redirect URL") 16 | } 17 | 18 | // Check if status code is valid 19 | if http.StatusText(c.RedirectStatus) == "" { 20 | return nil, fmt.Errorf("invalid HTTP status code: %d", c.RedirectStatus) 21 | } 22 | 23 | u, err := url.Parse(args[0]) 24 | if err != nil { return nil, err } 25 | if u.Scheme == "" { 26 | u.Scheme = "http" 27 | } 28 | 29 | route := &server.Route{ 30 | Pattern: "/", 31 | DoneHandlerFunc: func(w http.ResponseWriter, r *http.Request) { 32 | w.WriteHeader(http.StatusGone) 33 | w.Write([]byte("gone")) 34 | }, 35 | } 36 | if c.ExitOnFail { 37 | route.MaxRequests = 1 38 | } else { 39 | route.MaxOK = 1 40 | } 41 | 42 | header := http.Header{} 43 | for _, rh := range c.RawHeaders { 44 | parts := strings.SplitN(rh, ":", 2) 45 | if len(parts) < 2 { 46 | err := fmt.Errorf("invalid header: %s", rh) 47 | return nil, err 48 | } 49 | k := strings.TrimSpace(parts[0]) 50 | v := strings.TrimSpace(parts[1]) 51 | header.Set(k, v) 52 | } 53 | 54 | route.HandlerFunc = handlers.HandleRedirect(u.String(), c.RedirectStatus, !c.AllowBots, header, srvr.InfoLog) 55 | 56 | return route, nil 57 | } 58 | -------------------------------------------------------------------------------- /cmd/conf/server.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | import ( 4 | "fmt" 5 | "github.com/forestnode-io/oneshot/internal/handlers" 6 | "github.com/forestnode-io/oneshot/internal/server" 7 | "log" 8 | "net/http" 9 | "os" 10 | "time" 11 | ) 12 | 13 | func (c *Conf) SetupServer(srvr *server.Server, args []string, ips []string) error { 14 | var err error 15 | 16 | srvr.Host = c.Host 17 | srvr.Port = c.Port 18 | srvr.CertFile = c.CertFile 19 | srvr.KeyFile = c.KeyFile 20 | 21 | // Set reachable addresses and append port 22 | srvr.HostAddresses = []string{} 23 | for _, ip := range ips { 24 | srvr.HostAddresses = append(srvr.HostAddresses, ip+":"+c.Port) 25 | } 26 | 27 | // Add the loggers to the server based on users preference 28 | if !c.NoInfo && !c.NoError { 29 | srvr.InfoLog = log.New(os.Stdout, "", 0) 30 | } 31 | if !c.NoError { 32 | srvr.ErrorLog = log.New(os.Stderr, "error :: ", log.LstdFlags|log.Lshortfile) 33 | } 34 | 35 | // Create route handler depending on what the user wants to do 36 | var route *server.Route 37 | switch c.Mode() { 38 | case DownloadMode: 39 | route, err = c.setupDownloadRoute(args, srvr) 40 | case CGIMode: 41 | route, err = c.setupCGIRoute(args, srvr) 42 | case UploadMode: 43 | route, err = c.setupUploadRoute(args, srvr) 44 | case RedirectMode: 45 | route, err = c.setupRedirectRoute(args, srvr) 46 | } 47 | if err != nil { 48 | return err 49 | } 50 | 51 | // Are we doing basic web auth? 52 | if c.Password != "" || c.Username != "" { 53 | // Wrap the route handler with authentication middle-ware 54 | route.HandlerFunc = handlers.Authenticate(c.Username, c.Password, 55 | func(w http.ResponseWriter, r *http.Request) { 56 | w.Header().Set("WWW-Authenticate", "Basic") 57 | w.WriteHeader(http.StatusUnauthorized) 58 | }, route.HandlerFunc) 59 | } 60 | 61 | srvr.AddRoute(route) 62 | 63 | // Do we need to show any generated credentials? 64 | if c.randPass || c.randUser { 65 | msg := "" 66 | if c.randUser { 67 | msg += fmt.Sprintf( 68 | "generated random username: %s\n", 69 | c.Username, 70 | ) 71 | } 72 | if c.randPass { 73 | msg += fmt.Sprintf( 74 | "generated random password: %s\n", 75 | c.Password, 76 | ) 77 | } 78 | 79 | // Are we uploading to stdout? If so, we can't print info messages to stdout 80 | uploadToStdout := c.Upload && len(args) == 0 && c.Dir == "" && c.FileName == "" 81 | if uploadToStdout || srvr.InfoLog == nil { 82 | // oneshot will only print received file to stdout so we print to stderr or a file instead 83 | if srvr.ErrorLog == nil { 84 | f, err := os.Create("./oneshot-credentials.txt") 85 | if err != nil { 86 | f.Close() 87 | return err 88 | } 89 | msg += "\n" + time.Now().Format("15:04:05.000 MST 2 Jan 2006") 90 | _, err = f.WriteString(msg) 91 | if err != nil { 92 | f.Close() 93 | return err 94 | } 95 | f.Close() 96 | c.credFileLoc = f.Name() 97 | } else { 98 | srvr.ErrorLog.Printf(msg) 99 | } 100 | } else { 101 | srvr.InfoLog.Printf(msg) 102 | } 103 | } 104 | 105 | return nil 106 | } 107 | -------------------------------------------------------------------------------- /cmd/conf/tls.go: -------------------------------------------------------------------------------- 1 | package conf 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/rsa" 6 | "crypto/x509" 7 | "crypto/x509/pkix" 8 | "encoding/pem" 9 | "github.com/spf13/pflag" 10 | "io/ioutil" 11 | "math/big" 12 | "net" 13 | "os" 14 | "path/filepath" 15 | "strings" 16 | "time" 17 | ) 18 | 19 | func genCertAndKey(port string) (location string, err error) { 20 | ips := []net.IP{} 21 | 22 | addrs, err := net.InterfaceAddrs() 23 | if err != nil { 24 | return "", err 25 | } 26 | 27 | var home string 28 | for _, addr := range addrs { 29 | saddr := addr.String() 30 | 31 | if strings.Contains(saddr, "::") { 32 | continue 33 | } 34 | 35 | parts := strings.Split(saddr, "/") 36 | 37 | if parts[0] == "127.0.0.1" || parts[0] == "localhost" { 38 | home = parts[0] 39 | continue 40 | } 41 | 42 | ips = append(ips, net.ParseIP(parts[0])) 43 | } 44 | if len(ips) == 0 { 45 | ips = append(ips, net.ParseIP(home)) 46 | } 47 | 48 | max := new(big.Int).Lsh(big.NewInt(1), 128) 49 | serialNumber, _ := rand.Int(rand.Reader, max) 50 | subject := pkix.Name{ 51 | Organization: []string{"Raphael Reyna"}, 52 | OrganizationalUnit: []string{"oneshot"}, 53 | CommonName: "oneshot", 54 | } 55 | template := x509.Certificate{ 56 | SerialNumber: serialNumber, 57 | Subject: subject, 58 | NotBefore: time.Now(), 59 | NotAfter: time.Now().Add(365 * 24 * time.Hour), 60 | KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, 61 | ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, 62 | IPAddresses: ips, 63 | } 64 | pk, _ := rsa.GenerateKey(rand.Reader, 2048) 65 | 66 | derBytes, _ := x509.CreateCertificate(rand.Reader, &template, &template, &pk.PublicKey, pk) 67 | 68 | tempDir, err := ioutil.TempDir("", "oneshot") 69 | if err != nil { 70 | os.RemoveAll(tempDir) 71 | return "", err 72 | } 73 | certOut, _ := os.Create(filepath.Join(tempDir, "cert.pem")) 74 | pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes}) 75 | certOut.Close() 76 | 77 | keyOut, _ := os.Create(filepath.Join(tempDir, "key.pem")) 78 | pem.Encode(keyOut, &pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(pk)}) 79 | keyOut.Close() 80 | 81 | return tempDir, nil 82 | } 83 | 84 | // setupCertAndKey checks to see if we need to self-sign any certificates, and if so it returns their location 85 | func (c *Conf) SetupCertAndKey(fs *pflag.FlagSet) (location string, err error) { 86 | 87 | if (fs.Changed("tls-key") && fs.Changed("tls-cert") && 88 | c.CertFile == "" && c.KeyFile == "") || c.Sstls { 89 | location, err := genCertAndKey(c.Port) 90 | if err != nil { 91 | return "", err 92 | } 93 | 94 | c.CertFile = filepath.Join(location, "cert.pem") 95 | c.KeyFile = filepath.Join(location, "key.pem") 96 | 97 | return location, nil 98 | } 99 | 100 | return "", nil 101 | } 102 | -------------------------------------------------------------------------------- /cmd/conf/windows.go: -------------------------------------------------------------------------------- 1 | // +build windows 2 | 3 | package conf 4 | 5 | func init() { 6 | archiveMethodDefault = "zip" 7 | shellDefault = `C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe` 8 | noUnixNormDefault = true 9 | } 10 | -------------------------------------------------------------------------------- /cmd/mdns.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/grandcat/zeroconf" 5 | "github.com/forestnode-io/oneshot/internal/server" 6 | "os" 7 | "strconv" 8 | ) 9 | 10 | func (a *App) MDNS(version string, srvr *server.Server) error { 11 | // If we are using mdns, the zeroconf server needs to be started up, 12 | // and the human readable address needs to be prepended to the list of ip addresses. 13 | conf := a.conf 14 | if conf.Mdns { 15 | portN, err := strconv.ParseInt(conf.Port, 10, 32) 16 | if err != nil { 17 | return err 18 | } 19 | 20 | mdnsSrvr, err := zeroconf.Register( 21 | "oneshot", 22 | "_http._tcp", 23 | "local.", 24 | int(portN), 25 | []string{"version=" + version}, 26 | nil, 27 | ) 28 | defer mdnsSrvr.Shutdown() 29 | if err != nil { 30 | return err 31 | } 32 | 33 | host, err := os.Hostname() 34 | if err != nil { 35 | return err 36 | } 37 | 38 | srvr.HostAddresses = append( 39 | []string{host + ".local" + ":" + conf.Port}, 40 | srvr.HostAddresses..., 41 | ) 42 | } 43 | return nil 44 | } 45 | -------------------------------------------------------------------------------- /doc/man/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/forestnode-io/oneshot/cmd" 5 | "github.com/spf13/cobra/doc" 6 | "log" 7 | "os" 8 | ) 9 | 10 | func main() { 11 | app, err := cmd.NewApp() 12 | if err != nil { 13 | log.Println(err) 14 | os.Exit(1) 15 | } 16 | app.SetFlags() 17 | header := doc.GenManHeader{ 18 | Title: "ONESHOT", 19 | Section: "1", 20 | Source: "https://github.com/forestnode-io/oneshot", 21 | } 22 | err = doc.GenMan(app.Cmd(), &header, os.Stdout) 23 | if err != nil { 24 | log.Print(err) 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /doc/md/0_video.md: -------------------------------------------------------------------------------- 1 | #### A video overview of oneshot (thanks to Brodie Robertson) 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /doc/md/1_installation.md: -------------------------------------------------------------------------------- 1 | ### Installation 2 | 3 | There are multiple ways of obtaining oneshot: 4 | 5 | #### Linux / macOS 6 | Copy and paste any of these commands into your terminal to install oneshot. 7 | For some portion of Linux users, there are .deb and .rpm packages available in the [release page](https://github.com/forestnode-io/oneshot/releases). 8 | 9 | ##### Download binary (easiest) 10 | ```bash 11 | curl -L https://github.com/forestnode-io/oneshot/raw/master/install.sh | sudo bash 12 | ``` 13 | 14 | ##### Brew 15 | ```bash 16 | brew tap raphaelreyna/homebrew-repo 17 | brew install oneshot 18 | ``` 19 | 20 | ##### Go get 21 | ```bash 22 | go get -u -v github.com/forestnode-io/oneshot 23 | ``` 24 | 25 | ##### Compiling from source 26 | ```bash 27 | git clone github.com/forestnode-io/oneshot 28 | cd oneshot 29 | sudo make install 30 | ``` 31 | 32 | #### Arch Linux users 33 | Oneshot AUR page. 34 | 35 | 36 | #### Windows 37 | 38 | ##### Download executable 39 | Head over to the [release page](https://github.com/forestnode-io/oneshot/releases) and download the windows .zip file. 40 | 41 | ##### Go get 42 | ```powershell 43 | go get -u -v github.com/forestnode-io/oneshot 44 | ``` 45 | -------------------------------------------------------------------------------- /doc/md/2_goneshot.md: -------------------------------------------------------------------------------- 1 | ### Windows GUI 2 | Windows users might be interested in checkout [Goneshot](https://github.com/raphaelreyna/goneshot)(beta). 3 | If for some reason, you would rather *not* use a command line, [Goneshot](https://github.com/raphaelreyna/goneshot)(beta) wraps oneshot with a GUI (graphical user interface) which might be easier to use. A macOS version will probably be made at some point. 4 | -------------------------------------------------------------------------------- /doc/md/3_examples.md: -------------------------------------------------------------------------------- 1 | ### Use Cases & Examples 2 | 3 | #### Send a file 4 | ```bash 5 | $ oneshot path/to/file.txt 6 | ``` 7 | Then, from a browser (or any HTTP client) simply go to your computers I.P. address and the file download will be triggered. 8 | 9 | #### Send a file securely 10 | ```bash 11 | $ oneshot -U username -W path/to/file.txt 12 | ``` 13 | The `-W` option will cause oneshot to prompt you for a password. 14 | Oneshot also supports HTTPS, simply pass in the key and certificate using the `--tls-key` and `--tls-cert` flags. 15 | 16 | #### Receive a file 17 | ```bash 18 | $ oneshot -u . 19 | ``` 20 | The `-u` option is used for receiving data from the client. 21 | A connecting browser will be prompted to upload a file which oneshot then save to the current directory. 22 | 23 | #### Receive a file to standard out 24 | ```bash 25 | $ oneshot -u | jq '.first_name' 26 | ``` 27 | If the `-u` option is used and no directory is given, oneshot will write the received file to its standard out. 28 | 29 | #### Serve up a first-come-first-serve web page 30 | ```bash 31 | $ oneshot -D my/web/page.html 32 | ``` 33 | The `-D` flag tells oneshot to not trigger a download client-side. 34 | 35 | #### Send the results of a lengthy process 36 | ```bash 37 | $ sudo apt update | oneshot -n apt-update.txt 38 | ``` 39 | Oneshot can transfer from its standard input; by default files are given a random name. 40 | The optional flag `-n` sets the name of the file. 41 | 42 | #### Wait until someone provides credentials to start a process, then send its output 43 | ```bash 44 | $ oneshot -U "" -P password -c my_non-cgi_script.sh 45 | ``` 46 | Oneshot can run your scripts and programs in a CGI flexible CGI environment. 47 | Even non-CGI executables may be used; oneshot will provide its own default headers or you can set your own using the `-H` flag. 48 | Passing in an empty value (`""`) for `-U, --username` or `-P, --password` will result in a randomly generate username or password. 49 | 50 | #### Create a single-fire api in a single line 51 | ```bash 52 | $ oneshot -D -S 'echo "hello $(jq -r '.name')!"' 53 | ``` 54 | Here, the `-S` flag tells oneshot to run its input as a shell command in a flexible CGI environment. 55 | 56 | #### Create a 3-way transaction 57 | ```bash 58 | $ oneshot -D -S 'oneshot -p 8081 some_asset.mp3' 59 | ``` 60 | In this scenario, Alice runs oneshot, Bob connects to Alice's machine and his browser hangs until Carol also connects; Bob then receives the mp3 file. 61 | 62 | #### Receive a file, do work on it locally and send back the results 63 | ```bash 64 | $ oneshot -u | gofmt | oneshot -J 65 | ``` 66 | The `-J` flag we are using here tells oneshot to only start serving HTTP once it has received an EOF from its stdin. 67 | This allows us to create unix pipelines without needing to specify a different port for each instance of oneshot. 68 | In this scenario, the user would upload or type in some Go code and upon hitting the back button (refresh won't work !) or going back to the original URL, the user will receive their formatted Go code. 69 | 70 | -------------------------------------------------------------------------------- /doc/md/4_bugs_contributing.md: -------------------------------------------------------------------------------- 1 | ### Reporting Bugs, Feature Requests & Contributing 2 | Please report any bugs or issues [here](https://github.com/forestnode-io/oneshot/issues). 3 | 4 | I consider oneshot to be *nearly* feature complete; feature requests and contributions are welcome. 5 | -------------------------------------------------------------------------------- /doc/md/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "github.com/forestnode-io/oneshot/cmd" 6 | "github.com/spf13/cobra/doc" 7 | "io" 8 | "log" 9 | "os" 10 | "path/filepath" 11 | "sort" 12 | ) 13 | 14 | const logo = ` 15 | 16 | ` 17 | 18 | func main() { 19 | app, err := cmd.NewApp() 20 | if err != nil { 21 | log.Println(err) 22 | os.Exit(1) 23 | } 24 | app.SetFlags() 25 | 26 | mdBuffer := &bytes.Buffer{} 27 | err = doc.GenMarkdown(app.Cmd(), mdBuffer) 28 | if err != nil { 29 | panic(err) 30 | } 31 | 32 | // Logo 33 | os.Stdout.Write([]byte(logo)) 34 | 35 | parts := bytes.Split(mdBuffer.Bytes(), []byte(`### Synopsis`)) 36 | os.Stdout.Write(parts[0]) 37 | 38 | here, err := os.Open(".") 39 | defer here.Close() 40 | if err != nil { 41 | panic(err) 42 | } 43 | files, _ := here.Readdirnames(0) 44 | sort.Strings(files) 45 | var file *os.File 46 | for _, fName := range files { 47 | if filepath.Ext(fName) == ".md" { 48 | file, err = os.Open(fName) 49 | if err != nil { 50 | panic(err) 51 | } 52 | os.Stdout.Write([]byte("\n")) 53 | io.Copy(os.Stdout, file) 54 | file.Close() 55 | os.Stdout.Write([]byte("\n")) 56 | } 57 | } 58 | 59 | os.Stdout.Write([]byte("\n### Synopsis\n")) 60 | os.Stdout.Write(parts[1]) 61 | } 62 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/forestnode-io/oneshot 2 | 3 | go 1.18 4 | 5 | require ( 6 | github.com/google/uuid v1.2.0 7 | github.com/gorilla/mux v1.8.0 8 | github.com/grandcat/zeroconf v1.0.0 9 | github.com/jf-tech/iohelper v1.0.2 10 | github.com/raphaelreyna/ez-cgi v0.7.3 11 | github.com/spf13/cobra v1.4.0 12 | github.com/spf13/pflag v1.0.5 13 | ) 14 | 15 | require ( 16 | github.com/cenkalti/backoff v2.2.1+incompatible // indirect 17 | github.com/cpuguy83/go-md2man/v2 v2.0.1 // indirect 18 | github.com/inconshreveable/mousetrap v1.0.0 // indirect 19 | github.com/miekg/dns v1.1.27 // indirect 20 | github.com/russross/blackfriday/v2 v2.1.0 // indirect 21 | golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550 // indirect 22 | golang.org/x/net v0.0.0-20200602114024-627f9648deb9 // indirect 23 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd // indirect 24 | gopkg.in/yaml.v2 v2.4.0 // indirect 25 | ) 26 | -------------------------------------------------------------------------------- /go.work: -------------------------------------------------------------------------------- 1 | go 1.21 2 | 3 | use ( 4 | . 5 | ./v2 6 | ) 7 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # error codes 4 | # 0 - exited without problems 5 | # 1 - parameters not supported were used or some unexpected error occurred 6 | # 2 - OS not supported by this script 7 | # 3 - installed version of oneshot is up to date 8 | 9 | set -e 10 | 11 | usage() { echo "Usage: curl https://github.com/forestnode-io/oneshot/raw/master/install.sh | sudo bash " 1>&2; exit 1; } 12 | 13 | #create tmp directory and move to it with macOS compatibility fallback 14 | tmp_dir=`mktemp -d 2>/dev/null || mktemp -d -t 'oneshot-install.XXXXXXXXXX'`; cd $tmp_dir 15 | 16 | #check installed version of oneshot to determine if update is necessary 17 | version=`oneshot -v 2>>errors | head -n 1 | awk '{print $4}'` 18 | current_version=`curl -s -L https://github.com/forestnode-io/oneshot/raw/master/version.txt | tr -d "v"` 19 | if [ "$version" = "$current_version" ]; then 20 | printf "\nThe latest version of oneshot ${version} is already installed.\n\n" 21 | exit 3 22 | fi 23 | 24 | #detect the platform 25 | OS="`uname`" 26 | case $OS in 27 | Linux) 28 | OS='linux' 29 | ;; 30 | Darwin) 31 | OS='macos' 32 | ;; 33 | *) 34 | echo 'OS not supported' 35 | exit 2 36 | ;; 37 | esac 38 | 39 | ARCH_TYPE="`uname -m`" 40 | case $ARCH_TYPE in 41 | x86_64|amd64) 42 | ARCH_TYPE='x86_64' 43 | ;; 44 | i?86|x86) 45 | ARCH_TYPE='386' 46 | ;; 47 | arm*) 48 | ARCH_TYPE='arm' 49 | ;; 50 | aarch64) 51 | ARCH_TYPE='arm64' 52 | ;; 53 | *) 54 | echo 'OS type not supported' 55 | exit 2 56 | ;; 57 | esac 58 | 59 | #download and untar 60 | download_link="https://github.com/forestnode-io/oneshot/releases/download/v${current_version}/oneshot_${current_version}.${OS}-${ARCH_TYPE}.tar.gz" 61 | oneshot_tarball="oneshot_${current_version}.${OS}-${ARCH_TYPE}.tar.gz" 62 | 63 | curl -s -O -L $download_link 64 | untar_dir="oneshot_untar" 65 | mkdir $untar_dir 66 | tar -xzf $oneshot_tarball -C $untar_dir 67 | cd $untar_dir 68 | 69 | #install oneshot 70 | case $OS in 71 | 'linux') 72 | cp oneshot /usr/bin/oneshot 73 | chmod 755 /usr/bin/oneshot 74 | chown root:root /usr/bin/oneshot 75 | ;; 76 | 'macos') 77 | mkdir -p /usr/local/bin 78 | cp oneshot /usr/local/bin/oneshot 79 | ;; 80 | *) 81 | echo 'OS not supported' 82 | exit 2 83 | esac 84 | 85 | 86 | # Let user know oneshot was installed 87 | version=`oneshot --version 2>>errors | head -n 1 | awk '{print $4}'` 88 | 89 | printf "\noneshot v${version} has successfully installed.\n" 90 | printf 'You may now run "oneshot -h" for help with using oneshot.\n' 91 | printf 'Visit https://github.com/forestnode-io/oneshot for more information.\n\n' 92 | exit 0 93 | -------------------------------------------------------------------------------- /integrations/emacs/oneshot.el: -------------------------------------------------------------------------------- 1 | (defun oneshot () 2 | "Download the current buffer over HTTP." 3 | (interactive) 4 | (shell-command-on-region 5 | (point-min) (point-max) 6 | "oneshot -q")) 7 | 8 | (defun oneshot-view () 9 | "View the current buffer in a browser." 10 | (interactive) 11 | (shell-command-on-region 12 | (point-min) (point-max) 13 | "oneshot -q")) 14 | 15 | (defun oneshot-upload () 16 | "Upload text into the current buffer over HTTP." 17 | (interactive) 18 | (insert 19 | (shell-command-to-string "oneshot -u"))) 20 | -------------------------------------------------------------------------------- /internal/file/tar.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "archive/tar" 5 | "compress/gzip" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | ) 11 | 12 | func tarball(paths []string, w io.Writer) error { 13 | gw := gzip.NewWriter(w) 14 | defer gw.Close() 15 | tw := tar.NewWriter(gw) 16 | defer tw.Close() 17 | 18 | formatName := func(name string) string { 19 | // needed for windows 20 | name = strings.ReplaceAll(name, `\`, `/`) 21 | if string(name[0]) == `/` { 22 | name = name[1:] 23 | } 24 | return name 25 | } 26 | 27 | writeFile := func(path, name string, info os.FileInfo) error { 28 | header := tar.Header{ 29 | Name: name, 30 | Size: info.Size(), 31 | Mode: int64(info.Mode()), 32 | ModTime: info.ModTime(), 33 | } 34 | if err := tw.WriteHeader(&header); err != nil { 35 | return err 36 | } 37 | 38 | currFile, err := os.Open(path) 39 | if err != nil { 40 | return err 41 | } 42 | 43 | _, err = io.Copy(tw, currFile) 44 | currFile.Close() 45 | if err != nil { 46 | return err 47 | } 48 | 49 | return nil 50 | } 51 | 52 | walkFunc := func(path string) func(string, os.FileInfo, error) error { 53 | dir := filepath.Dir(path) 54 | return func(fp string, info os.FileInfo, err error) error { 55 | if info.IsDir() { 56 | return nil 57 | } else if err != nil { 58 | return err 59 | } 60 | 61 | name := strings.TrimPrefix(fp, dir) 62 | name = formatName(name) 63 | 64 | if err = writeFile(fp, name, info); err != nil { 65 | return err 66 | } 67 | 68 | return nil 69 | } 70 | } 71 | 72 | // Loop over files to be archived 73 | for _, path := range paths { 74 | info, err := os.Stat(path) 75 | if err != nil { 76 | return err 77 | } 78 | if !info.IsDir() { // Archiving a file 79 | name := filepath.Base(path) 80 | name = formatName(name) 81 | 82 | err = writeFile(path, name, info) 83 | if err != nil { 84 | return err 85 | } 86 | } else { // Archiving a directory; needs to be walked 87 | err := filepath.Walk(path, walkFunc(path)) 88 | if err != nil { 89 | return err 90 | } 91 | } 92 | } 93 | 94 | return nil 95 | } 96 | -------------------------------------------------------------------------------- /internal/file/zip.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | z "archive/zip" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | ) 10 | 11 | func zip(paths []string, w io.Writer) error { 12 | zw := z.NewWriter(w) 13 | defer zw.Close() 14 | 15 | formatName := func(name string) string { 16 | // needed for windows 17 | name = strings.ReplaceAll(name, `\`, `/`) 18 | if string(name[0]) == `/` { 19 | name = name[1:] 20 | } 21 | return name 22 | } 23 | 24 | writeFile := func(path, name string, info os.FileInfo) error { 25 | zFile, err := zw.Create(name) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | currFile, err := os.Open(path) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | _, err = io.Copy(zFile, currFile) 36 | currFile.Close() 37 | if err != nil { 38 | return err 39 | } 40 | 41 | return nil 42 | } 43 | 44 | walkFunc := func(path string) func(string, os.FileInfo, error) error { 45 | dir := filepath.Dir(path) 46 | return func(fp string, info os.FileInfo, err error) error { 47 | if info.IsDir() { 48 | return nil 49 | } else if err != nil { 50 | return err 51 | } 52 | 53 | name := strings.TrimPrefix(fp, dir) 54 | name = formatName(name) 55 | 56 | if err = writeFile(fp, name, info); err != nil { 57 | return err 58 | } 59 | 60 | return nil 61 | } 62 | } 63 | 64 | // Loop over files to be archived 65 | for _, path := range paths { 66 | info, err := os.Stat(path) 67 | if err != nil { 68 | return err 69 | } 70 | if !info.IsDir() { // Archiving a file 71 | name := filepath.Base(path) 72 | name = formatName(name) 73 | 74 | err = writeFile(path, name, info) 75 | if err != nil { 76 | return err 77 | } 78 | } else { // Archiving a directory; needs to be walked 79 | err := filepath.Walk(path, walkFunc(path)) 80 | if err != nil { 81 | return err 82 | } 83 | } 84 | } 85 | 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /internal/handlers/authenticate.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | srvr "github.com/forestnode-io/oneshot/internal/server" 7 | ) 8 | 9 | func Authenticate(username, password string, unauthenticated http.HandlerFunc, authenticated srvr.FailableHandler) srvr.FailableHandler { 10 | return func(w http.ResponseWriter, r *http.Request) error { 11 | u, p, ok := r.BasicAuth() 12 | if !ok { 13 | unauthenticated(w, r) 14 | return fmt.Errorf("%s connected without providing username and password", r.RemoteAddr) 15 | } 16 | // Whichever field is missing is not checked 17 | if username != "" && username != u { 18 | unauthenticated(w, r) 19 | return fmt.Errorf("%s connected with invalid username and password", r.RemoteAddr) 20 | } 21 | if password != "" && password != p { 22 | unauthenticated(w, r) 23 | return fmt.Errorf("%s connected with invalid username and password", r.RemoteAddr) 24 | } 25 | return authenticated(w, r) 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /internal/handlers/bots.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | "strings" 5 | ) 6 | 7 | // botHeaders are the known User-Agent header values in use by bots / machines 8 | var botHeaders []string = []string{ 9 | "bot", 10 | "Bot", 11 | "facebookexternalhit", 12 | } 13 | 14 | func isBot(headers []string) bool { 15 | for _, header := range headers { 16 | for _, botHeader := range botHeaders { 17 | if strings.Contains(header, botHeader) { return true } 18 | } 19 | } 20 | 21 | return false 22 | } 23 | -------------------------------------------------------------------------------- /internal/handlers/cgi.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | ezcgi "github.com/raphaelreyna/ez-cgi/pkg/cgi" 5 | srvr "github.com/forestnode-io/oneshot/internal/server" 6 | "log" 7 | "net/http" 8 | "time" 9 | ) 10 | 11 | func HandleCGI(handler *ezcgi.Handler, name, mime string, noBots bool, infoLog *log.Logger) srvr.FailableHandler { 12 | // Creating logging messages and functions 13 | msg := "transfer complete:\n" 14 | msg += "\tname: %s\n" 15 | if mime != "" { 16 | msg += "\tMIME type: %s\n" 17 | } 18 | msg += "\tstart time: %s\n" 19 | msg += "\tduration: %s\n" 20 | msg += "\tdestination: %s\n" 21 | 22 | var iLog = func(format string, v ...interface{}) { 23 | if infoLog != nil { 24 | infoLog.Printf(format, v...) 25 | } 26 | } 27 | 28 | var printSummary = func(start time.Time, 29 | duration time.Duration, client string) { 30 | 31 | startTime := start.Format("15:04:05.000 MST 2 Jan 2006") 32 | durationTime := duration.Truncate(time.Millisecond).String() 33 | 34 | if mime != "" { 35 | iLog(msg, name, mime, startTime, durationTime, client) 36 | } else { 37 | iLog(msg, name, startTime, durationTime, client) 38 | } 39 | } 40 | 41 | // Define and return the actual handler 42 | return func(w http.ResponseWriter, r *http.Request) error { 43 | // Filter out requests from bots, iMessage, etc. by checking the User-Agent header for known bot headers 44 | if headers, exists := r.Header["User-Agent"]; exists && noBots { 45 | if isBot(headers) { 46 | w.WriteHeader(http.StatusOK) 47 | return srvr.OKNotDoneErr 48 | } 49 | } 50 | iLog("connected: %s", r.RemoteAddr) 51 | before := time.Now() 52 | handler.ServeHTTP(w, r) 53 | duration := time.Since(before) 54 | printSummary(before, duration, r.RemoteAddr) 55 | return nil 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /internal/handlers/redirect.go: -------------------------------------------------------------------------------- 1 | package handlers 2 | 3 | import ( 4 | srvr "github.com/forestnode-io/oneshot/internal/server" 5 | "log" 6 | "net/http" 7 | "time" 8 | ) 9 | 10 | func HandleRedirect(url string, statCode int, noBots bool, header http.Header, infoLog *log.Logger) srvr.FailableHandler { 11 | // Creating logging messages and functions 12 | msg := "redirect complete:\n" 13 | msg += "\tstart time: %s\n" 14 | msg += "\tclient I.P. address: %s\n" 15 | msg += "\tredirected to: %s\n" 16 | msg += "\tHTTP status: %d - %s\n" 17 | 18 | var iLog = func(format string, v ...interface{}) { 19 | if infoLog != nil { 20 | infoLog.Printf(format, v...) 21 | } 22 | } 23 | 24 | var printSummary = func(start time.Time, client string, rt string) { 25 | 26 | startTime := start.Format("15:04:05.000 MST 2 Jan 2006") 27 | 28 | iLog(msg, startTime, client, rt, statCode, http.StatusText(statCode)) 29 | } 30 | 31 | // Define and return the actual handler 32 | return func(w http.ResponseWriter, r *http.Request) error { 33 | // Filter out requests from bots, iMessage, etc. by checking the User-Agent header for known bot headers 34 | if headers, exists := r.Header["User-Agent"]; exists && noBots { 35 | if isBot(headers) { 36 | w.WriteHeader(http.StatusOK) 37 | return srvr.OKNotDoneErr 38 | } 39 | } 40 | 41 | iLog("connected: %s", r.RemoteAddr) 42 | // Set any headers added by the user via flags before redirecting 43 | for key := range header { 44 | w.Header().Set(key, header.Get(key)) 45 | } 46 | http.Redirect(w, r, url, statCode) 47 | printSummary(time.Now(), r.RemoteAddr, url) 48 | return nil 49 | } 50 | } 51 | -------------------------------------------------------------------------------- /internal/server/add-route.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | "sync" 6 | ) 7 | 8 | // AddRoute adds a new single fire route to the server. 9 | func (s *Server) AddRoute(route *Route) { 10 | if s.wg == nil { 11 | s.wg = &sync.WaitGroup{} 12 | s.wg.Add(1) 13 | go func() { 14 | s.wg.Wait() 15 | s.Done <- s.finishedRoutes 16 | close(s.Done) 17 | }() 18 | } 19 | 20 | okMetric := true 21 | if route.MaxRequests != 0 { 22 | okMetric = false 23 | } else if route.MaxOK == 0 { 24 | route.MaxOK = 1 25 | } 26 | 27 | rr := s.router.HandleFunc(route.Pattern, func(w http.ResponseWriter, r *http.Request) { 28 | var rc int64 29 | var err error 30 | route.Lock() 31 | route.reqCount++ 32 | 33 | if okMetric { 34 | switch { 35 | case route.okCount >= route.MaxOK: 36 | route.DoneHandlerFunc(w, r) 37 | case route.okCount < route.MaxOK: 38 | err = route.HandlerFunc(w, r) 39 | 40 | if err == nil || err == OKDoneErr { 41 | route.okCount++ 42 | err = OKDoneErr 43 | } else if err != OKNotDoneErr { 44 | s.internalError(err.Error()) 45 | } 46 | 47 | if route.okCount == route.MaxOK { 48 | s.Lock() 49 | s.finishedRoutes[route] = err 50 | s.Unlock() 51 | s.wg.Done() 52 | } 53 | } 54 | route.Unlock() 55 | return 56 | } 57 | 58 | rc = route.reqCount 59 | route.Unlock() 60 | switch { 61 | case rc > route.MaxRequests: 62 | route.DoneHandlerFunc(w, r) 63 | case rc <= route.MaxRequests: 64 | err = route.HandlerFunc(w, r) 65 | if err == nil || err == OKDoneErr { 66 | route.Lock() 67 | route.okCount++ 68 | route.Unlock() 69 | err = OKDoneErr 70 | } else if err != OKNotDoneErr { 71 | s.internalError(err.Error()) 72 | } 73 | 74 | if rc == route.MaxRequests { 75 | s.Lock() 76 | s.finishedRoutes[route] = err 77 | s.Unlock() 78 | s.wg.Done() 79 | } 80 | } 81 | }) 82 | 83 | if len(route.Methods) > 0 { 84 | rr.Methods(route.Methods...) 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /internal/server/icon.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | _ "embed" 5 | "net/http" 6 | ) 7 | 8 | //go:embed icon.png 9 | var icon []byte 10 | 11 | func (s *Server) HandleIcon(w http.ResponseWriter, r *http.Request) { 12 | header := w.Header() 13 | header.Clone().Set("Content-Type", "image/png") 14 | w.Write(icon) 15 | } 16 | -------------------------------------------------------------------------------- /internal/server/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/forestnode-io/oneshot/22a2a6df3c517732c0e80e7312642add4b0069c5/internal/server/icon.png -------------------------------------------------------------------------------- /internal/server/route.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "net/http" 5 | "sync" 6 | "sync/atomic" 7 | ) 8 | 9 | // FailableHandler is an http.HandlerFunc that returns an error. 10 | // oneshot uses this error to determine when to exit. 11 | type FailableHandler func(w http.ResponseWriter, r *http.Request) error 12 | 13 | type Route struct { 14 | Pattern string 15 | Methods []string 16 | HandlerFunc FailableHandler 17 | DoneHandlerFunc http.HandlerFunc 18 | MaxOK int64 19 | MaxRequests int64 20 | 21 | reqCount int64 22 | okCount int64 23 | 24 | sync.Mutex 25 | } 26 | 27 | func (r *Route) RequestCount() int64 { 28 | return atomic.LoadInt64(&r.reqCount) 29 | } 30 | 31 | func (r *Route) OkCount() int64 { 32 | return atomic.LoadInt64(&r.okCount) 33 | } 34 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/forestnode-io/oneshot/cmd" 5 | "log" 6 | "math/rand" 7 | "os" 8 | "time" 9 | ) 10 | 11 | func main() { 12 | rand.Seed(time.Now().UTC().UnixNano()) 13 | 14 | app, err := cmd.NewApp() 15 | if err != nil { 16 | log.Println(err) 17 | os.Exit(1) 18 | } 19 | 20 | app.Start() 21 | } 22 | -------------------------------------------------------------------------------- /oneshot_banner.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/forestnode-io/oneshot/22a2a6df3c517732c0e80e7312642add4b0069c5/oneshot_banner.png -------------------------------------------------------------------------------- /release: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # Prepare oneshot release. 3 | # Covers things I don't know how to do using goreleaser. 4 | 5 | CURRENT_VERSION=`git describe --tag --abbrev=0` 6 | NEW_VERSION="" 7 | 8 | # Create README.md 9 | rm -f README.md 10 | make README.md 11 | git add README.md 12 | 13 | # Create man page 14 | rm -f oneshot.1 15 | make oneshot.1 16 | git add oneshot.1 17 | 18 | function updateVersion { 19 | read -p "new version (currently at ${CURRENT_VERSION}): " NEW_VERSION 20 | ## Make sure new version has valid format 21 | if [[ $NEW_VERSION =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]] 22 | then 23 | echo "${NEW_VERSION}" > version.txt 24 | git add version.txt 25 | else 26 | echo "invalid version format. try again." 27 | updateVersion 28 | fi 29 | } 30 | 31 | updateVersion 32 | 33 | # Remove old builds 34 | rm -rf dist 35 | 36 | # Show status and ask to commit 37 | git status 38 | read -p "commit and tag? [y/n]: " COMMIT 39 | if [[ $COMMIT == [nN] ]] 40 | then 41 | exit 0 42 | fi 43 | read -p "reuse message for commit and tag? [y/n]: " REUSE 44 | if [[ $REUSE == [yY] ]] 45 | then 46 | read -p "message: " MESSAGE 47 | git commit -m "${MESSAGE}" 48 | git tag -a "${NEW_VERSION}" -m "${MESSAGE}" 49 | else 50 | git commit 51 | git tag -a "${NEW_VERSION}" 52 | fi 53 | 54 | # Release 55 | read -p "release? [y/n]: " RELEASE && [[ $RELEASE == [nN] ]] && exit 0 56 | goreleaser 57 | -------------------------------------------------------------------------------- /v2/.gitignore: -------------------------------------------------------------------------------- 1 | build-output 2 | node_modules 3 | dist 4 | test 5 | .DS_Store 6 | dist/ 7 | -------------------------------------------------------------------------------- /v2/.gon/amd64.hcl: -------------------------------------------------------------------------------- 1 | source = ["./build-output/goreleaser/oneshot-darwin-amd64_darwin_amd64_v1/oneshot"] 2 | bundle_id = "io.forestnode.oneshot" 3 | 4 | apple_id { 5 | username = "raphaelreyna@protonmail.com" 6 | password = "@env:APPLE_DEV_PASSWORD" 7 | } 8 | 9 | sign { 10 | application_identity = "Developer ID Application: Raphael Reyna" 11 | } -------------------------------------------------------------------------------- /v2/.gon/arm64.hcl: -------------------------------------------------------------------------------- 1 | source = ["./build-output/goreleaser/oneshot-darwin-arm64_darwin_arm64/oneshot"] 2 | bundle_id = "io.forestnode.oneshot" 3 | 4 | apple_id { 5 | username = "raphaelreyna@protonmail.com" 6 | password = "@env:APPLE_DEV_PASSWORD" 7 | } 8 | 9 | sign { 10 | application_identity = "Developer ID Application: Raphael Reyna" 11 | } -------------------------------------------------------------------------------- /v2/Makefile: -------------------------------------------------------------------------------- 1 | APP_NAME ?= oneshot 2 | IMAGE_REGISTRY ?= docker.io/raphaelreyna 3 | GIT_REPO ?= github.com/forestnode-io/oneshot/v2 4 | 5 | VERSION=`git describe --tags --abbrev=0` 6 | VERSION_FLAG=$(GIT_REPO)/pkg/version.Version=$(VERSION) 7 | API_VERSION=v1.0.0 8 | API_VERSION_FLAG=$(GIT_REPO)/pkg/version.APIVersion=$(API_VERSION) 9 | STATIC_FLAG=-extldflags=-static 10 | GOBIN=$(shell go env GOPATH)/bin 11 | 12 | BUILD_OUTPUT_DIR=./build-output 13 | APP_LOCATION=$(BUILD_OUTPUT_DIR)/$(APP_NAME) 14 | WEBRTC_CLIENT_DIR=./browser/webrtc-client 15 | UPLOAD_CLIENT_DIR=./browser/upload-client 16 | 17 | $(APP_NAME): webrtc-client upload-client 18 | mkdir -p ./build-output 19 | go build -o $(APP_LOCATION) \ 20 | -trimpath \ 21 | -ldflags "${STATIC_FLAG} -X ${VERSION_FLAG} -X ${API_VERSION_FLAG} -s -w" \ 22 | ./cmd/... 23 | 24 | compressed: $(APP_NAME) 25 | upx --best --brute --no-lzma $(APP_LOCATION) 26 | 27 | $(APP_NAME).1: 28 | go run -ldflags "-X ${VERSION_FLAG}" \ 29 | ./build-tools/man/main.go > $(APP_LOCATION).1 30 | 31 | install-man-page: $(APP_NAME).1 32 | mv $(APP_LOCATION).1 $(MANPATH)/man1 33 | mandb 34 | 35 | .PHONY: image 36 | image: 37 | docker build -t $(IMAGE_REGISTRY)/$(APP_NAME):$(VERSION) . 38 | 39 | .PHONY: itest 40 | itest: $(APP_NAME) 41 | go test -count 1 -p 1 -timeout 30s ./integration_testing/... 42 | 43 | .PHONY: itest-full 44 | itest-without-internet: $(APP_NAME) 45 | go test -without-internet -count 1 -p 1 -timeout 30s ./integration_testing/... 46 | 47 | .PHONY: lint 48 | lint: 49 | $(GOBIN)/golangci-lint run 50 | 51 | dep: 52 | go mod download 53 | 54 | vet: 55 | go vet ./... 56 | 57 | .PHONY: webrtc-client 58 | webrtc-client: 59 | npm --prefix $(WEBRTC_CLIENT_DIR) i && npm --prefix $(WEBRTC_CLIENT_DIR) run build 60 | cp $(WEBRTC_CLIENT_DIR)/dist/main.minified.js ./pkg/commands/discovery-server/template/webrtc-client.js 61 | cp $(WEBRTC_CLIENT_DIR)/dist/sd-streams-polyfill.min.js ./pkg/commands/discovery-server/template/sd-streams-polyfill.min.js 62 | 63 | .PHONY: upload-client 64 | upload-client: 65 | npm --prefix $(UPLOAD_CLIENT_DIR) i && npm --prefix $(UPLOAD_CLIENT_DIR) run build 66 | cp $(UPLOAD_CLIENT_DIR)/dist/main.minified.js ./pkg/commands/receive/main.js 67 | 68 | clean: 69 | rm -rf $(BUILD_OUTPUT_DIR) 70 | rm -rf $(WEBRTC_CLIENT_DIR)/node_modules 71 | rm -rf $(WEBRTC_CLIENT_DIR)/dist 72 | rm -rf $(UPLOAD_CLIENT_DIR)/node_modules 73 | rm -rf $(UPLOAD_CLIENT_DIR)/dist 74 | rm ./pkg/commands/discovery-server/template/webrtc-client.js 75 | rm ./pkg/commands/discovery-server/template/sd-streams-polyfill.min.js -------------------------------------------------------------------------------- /v2/browser/upload-client/build.mjs: -------------------------------------------------------------------------------- 1 | import * as esbuild from 'esbuild'; 2 | import fs from 'node:fs'; 3 | import UglifyJS from 'uglify-js'; 4 | 5 | let buildHTML = { 6 | name: 'build-html', 7 | setup(build) { 8 | build.onEnd(async result => { 9 | let mainJS = fs.readFileSync('./dist/main.js', 'utf8'); 10 | mainJS = UglifyJS.minify(mainJS, { 11 | compress: false, 12 | mangle: true, 13 | }).code; 14 | fs.writeFileSync('./dist/main.minified.js', mainJS, 'utf8', (err) => { 15 | if (err) throw err; 16 | }); 17 | }); 18 | }, 19 | } 20 | 21 | await esbuild.build({ 22 | entryPoints: ['./main.ts'], 23 | bundle: true, 24 | outfile: './dist/main.js', 25 | target: ['chrome58', 'firefox57', 'safari11', 'edge16'], 26 | plugins: [buildHTML], 27 | }); -------------------------------------------------------------------------------- /v2/browser/upload-client/main.ts: -------------------------------------------------------------------------------- 1 | import { sendFormData } from "./src/sendFormData"; 2 | import { sendString } from "./src/sendString"; 3 | 4 | function main() { 5 | console.log("running main") 6 | addFormSubmit(); 7 | addStringSubmit(); 8 | } 9 | 10 | function addFormSubmit() { 11 | const formEl = document.getElementById("file-form") as HTMLFormElement; 12 | if (!formEl) { 13 | return; 14 | } 15 | 16 | formEl.addEventListener("submit", (e) => { 17 | e.preventDefault(); 18 | e.stopPropagation(); 19 | 20 | const formData = new FormData(formEl); 21 | responsePromiseHandler(sendFormData(formData)); 22 | }); 23 | } 24 | 25 | function addStringSubmit() { 26 | const taEl = document.getElementById("text-input") as HTMLTextAreaElement; 27 | const formEl = document.getElementById("text-form") as HTMLFormElement; 28 | 29 | if (!taEl || !formEl) { 30 | return; 31 | } 32 | 33 | formEl.addEventListener("submit", (e) => { 34 | e.preventDefault(); 35 | e.stopPropagation(); 36 | 37 | responsePromiseHandler(sendString(taEl.value)); 38 | }); 39 | } 40 | 41 | function responsePromiseHandler(p: Promise) { 42 | p.then((response) => { 43 | if (response.ok) { 44 | console.log("Transfer succeeded"); 45 | document.body.innerHTML = "Transfer succeeded"; 46 | } else { 47 | const msg = "Transfer failed: " + response.status.toString + " " + response.statusText; 48 | console.log(msg); 49 | document.body.innerHTML = msg; 50 | } 51 | }).catch((err) => { 52 | if (err instanceof Error) { 53 | if (err.message === "cannot send empty data") { 54 | console.log(err.message); 55 | alert(err.message); 56 | return; 57 | } 58 | } 59 | const msg = "Transfer failed: " + err; 60 | console.log(msg) 61 | document.body.innerHTML = msg; 62 | }); 63 | } 64 | 65 | main(); -------------------------------------------------------------------------------- /v2/browser/upload-client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "oneshot-upload-client", 3 | "version": "0.0.1", 4 | "author": "Raphael Reyna", 5 | "main": "main.ts", 6 | "scripts": { 7 | "build": "node ./build.mjs" 8 | }, 9 | "devDependencies": { 10 | "typescript": "^4.9.5" 11 | }, 12 | "dependencies": { 13 | "esbuild": "^0.17.10", 14 | "uglify-js": "^3.17.4" 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /v2/browser/upload-client/src/sendFormData.ts: -------------------------------------------------------------------------------- 1 | export function sendFormData(formData: FormData): Promise { 2 | const lengths = []; 3 | var count = 0; 4 | 5 | for (const pair of formData.entries()) { 6 | count++; 7 | const entry = pair[1]; 8 | if (entry instanceof File) { 9 | const name = entry.name; 10 | const size = entry.size; 11 | 12 | lengths.push(name + "=" + size.toString()); 13 | } 14 | } 15 | 16 | if (count === 0) { 17 | return Promise.reject(new Error("cannot send empty data")); 18 | } 19 | 20 | return fetch("/", { 21 | method: "POST", 22 | headers: { 23 | "X-Oneshot-Multipart-Content-Lengths": lengths.join(";"), 24 | }, 25 | body: formData, 26 | }) 27 | } -------------------------------------------------------------------------------- /v2/browser/upload-client/src/sendString.ts: -------------------------------------------------------------------------------- 1 | export function sendString(string: string): Promise { 2 | if (string.length === 0) { 3 | return Promise.reject(new Error("cannot send empty data")); 4 | } 5 | 6 | return fetch("/", { 7 | method: "POST", 8 | headers: { 9 | "Content-Length": string.length.toString(), 10 | }, 11 | body: string, 12 | }) 13 | } -------------------------------------------------------------------------------- /v2/browser/webrtc-client/build.mjs: -------------------------------------------------------------------------------- 1 | import * as esbuild from 'esbuild'; 2 | import fs from 'node:fs'; 3 | import UglifyJS from 'uglify-js'; 4 | 5 | let buildHTML = { 6 | name: 'build-html', 7 | setup(build) { 8 | build.onEnd(async result => { 9 | let mainJS = fs.readFileSync('./dist/main.js', 'utf8'); 10 | mainJS = UglifyJS.minify(mainJS, { 11 | compress: false, 12 | mangle: true, 13 | }).code; 14 | fs.writeFileSync('./dist/main.minified.js', mainJS, 'utf8', (err) => { 15 | if (err) throw err; 16 | }); 17 | fs.copyFileSync( 18 | './node_modules/@stardazed/streams-polyfill/dist/sd-streams-polyfill.min.js', 19 | './dist/sd-streams-polyfill.min.js'); 20 | }); 21 | }, 22 | } 23 | 24 | await esbuild.build({ 25 | entryPoints: ['./main.ts'], 26 | bundle: true, 27 | outfile: './dist/main.js', 28 | target: ['chrome58', 'firefox57', 'safari11', 'edge16'], 29 | plugins: [buildHTML], 30 | }); -------------------------------------------------------------------------------- /v2/browser/webrtc-client/main.ts: -------------------------------------------------------------------------------- 1 | import { HTTPOverWebRTCClient } from './src/webrtcClient'; 2 | 3 | type Config = { 4 | RTCConfiguration: RTCConfiguration; 5 | RTCSessionDescription: RTCSessionDescription; 6 | SessionID: string; 7 | }; 8 | 9 | declare global { 10 | interface Window { 11 | WebRTCClient: Function; 12 | rtcReady: boolean; 13 | sessionToken: string; 14 | basicAuthToken: string | undefined; 15 | config: Config | undefined; 16 | } 17 | }; 18 | 19 | // export webrtc client to global scope 20 | window.WebRTCClient = HTTPOverWebRTCClient; 21 | 22 | -------------------------------------------------------------------------------- /v2/browser/webrtc-client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@forestnode-io/http-over-webrtc", 3 | "version": "0.1.0", 4 | "author": "Raphael Reyna", 5 | "main": "main.ts", 6 | "scripts": { 7 | "build": "node ./build.mjs" 8 | }, 9 | "devDependencies": { 10 | "tsup": "^6.6.0", 11 | "typescript": "^4.9.5" 12 | }, 13 | "dependencies": { 14 | "@stardazed/streams-polyfill": "^2.4.0", 15 | "esbuild": "0.17.6", 16 | "uglify-js": "^3.17.4" 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /v2/browser/webrtc-client/src/browser/activateScriptTags.ts: -------------------------------------------------------------------------------- 1 | export function activateScriptTags(el: ChildNode | HTMLScriptElement) { 2 | if (el instanceof HTMLScriptElement) { 3 | el.parentNode?.replaceChild(cloneScript(el), el); 4 | } else { 5 | var i = -1, children = el.childNodes; 6 | while (++i < children.length) { 7 | activateScriptTags(children[i]); 8 | } 9 | } 10 | } 11 | 12 | function cloneScript(el: HTMLScriptElement) { 13 | var script = document.createElement("script"); 14 | script.text = el.innerHTML; 15 | 16 | var i = -1, attrs = el.attributes, attr; 17 | while (++i < attrs.length) { 18 | script.setAttribute((attr = attrs[i]).name, attr.value); 19 | } 20 | return script; 21 | } -------------------------------------------------------------------------------- /v2/browser/webrtc-client/src/browser/triggerDownload.ts: -------------------------------------------------------------------------------- 1 | export function triggerDownload(data: Blob, filename: string) { 2 | const a = document.createElement("a"); 3 | a.setAttribute("style", "display: none"); 4 | document.body.appendChild(a); 5 | 6 | const blob = new Blob([data], { type: "stream/octet" }); 7 | const url = window.URL.createObjectURL(blob); 8 | a.href = url; 9 | a.download = filename; 10 | a.click(); 11 | window.URL.revokeObjectURL(url); 12 | } 13 | -------------------------------------------------------------------------------- /v2/browser/webrtc-client/src/fetch/constants.ts: -------------------------------------------------------------------------------- 1 | export const DataChannelMTU = 16384; 2 | export const BufferedAmountLowThreshold = 1 * DataChannelMTU; // 2^0 MTU 3 | export const MaxBufferedAmount = 8 * DataChannelMTU; // 2^3 MTU 4 | 5 | export const boundary = "boundary"; -------------------------------------------------------------------------------- /v2/browser/webrtc-client/src/fetch/writeHeader.ts: -------------------------------------------------------------------------------- 1 | import { DataChannelMTU } from "./constants"; 2 | 3 | export async function writeHeader(channel: RTCDataChannel, resource: RequestInfo | URL, options?: RequestInit): Promise { 4 | const method = options?.method || 'GET'; 5 | let headerString = `${method} ${resource} HTTP/1.1\n`; 6 | 7 | let headers = options?.headers ? options!.headers! : new Headers(); 8 | if (headers instanceof Headers) { 9 | if (!headers.has('User-Agent')) { 10 | headers.append('User-Agent', navigator.userAgent); 11 | } 12 | headers.append("X-HTTPOverWebRTC", "true"); 13 | headers.forEach((value, key) => { 14 | headerString += `${key}: ${value}\n`; 15 | }); 16 | } else if (typeof headers === 'object') { 17 | if (headers instanceof Array) { 18 | var foundUserAgent = false; 19 | for (var i = 0; i < headers.length; i++) { 20 | headerString += `${headers[i][0]}: ${headers[i][1]}\n`; 21 | if (headers[i][0] === 'User-Agent') { 22 | foundUserAgent = true; 23 | } 24 | } 25 | if (!foundUserAgent) { 26 | headerString += `User-Agent: ${navigator.userAgent}\n`; 27 | } 28 | headerString += "X-HTTPOverWebRTC: true\n" 29 | } else { 30 | if (!headers['User-Agent']) { 31 | headers['User-Agent'] = navigator.userAgent; 32 | } 33 | headers['X-HTTPOverWebRTC'] = 'true'; 34 | for (const key in headers) { 35 | headerString += `${key}: ${headers[key]}\n`; 36 | } 37 | } 38 | } 39 | headerString += '\n'; 40 | 41 | console.log("writing header: ", headerString); 42 | 43 | const pump = sendPump(channel, headerString); 44 | try { 45 | pump(); 46 | } catch (e) { 47 | return Promise.reject(e); 48 | } 49 | 50 | return Promise.resolve(); 51 | } 52 | 53 | function sendPump(channel: RTCDataChannel, data: string): () => void { 54 | var mtu = DataChannelMTU; 55 | const s = function () { 56 | while (data.length) { 57 | if (channel.bufferedAmount > channel.bufferedAmountLowThreshold) { 58 | channel.onbufferedamountlow = () => { 59 | channel.onbufferedamountlow = null; 60 | s(); 61 | } 62 | } 63 | 64 | if (data.length < mtu) { 65 | mtu = data.length; 66 | } 67 | 68 | const chunk = data.slice(0, mtu); 69 | data = data.slice(mtu); 70 | try { 71 | channel.send(chunk); 72 | } catch (e) { 73 | if (e instanceof DOMException && e.name === 'InvalidStateError') { 74 | setTimeout(() => { 75 | try { 76 | channel.send(chunk); 77 | } catch (e) { 78 | throw e; 79 | } 80 | }, 500); 81 | } else { 82 | throw e; 83 | } 84 | } 85 | 86 | if (mtu != DataChannelMTU) { 87 | return; 88 | } 89 | } 90 | } 91 | 92 | return s; 93 | } -------------------------------------------------------------------------------- /v2/browser/webrtc-client/src/types.ts: -------------------------------------------------------------------------------- 1 | export type HTTPHeader = { 2 | [key: string]: string 3 | } 4 | 5 | export type StatusLine = { 6 | status: number; 7 | statusText: string; 8 | } -------------------------------------------------------------------------------- /v2/browser/webrtc-client/src/util.ts: -------------------------------------------------------------------------------- 1 | import { HTTPHeader, StatusLine } from './types'; 2 | 3 | export function parseHeader(header: string): HTTPHeader { 4 | const lines = header.split('\n'); 5 | const h: HTTPHeader = {}; 6 | for (const line of lines) { 7 | const splitPosition = line.search(':'); 8 | if (splitPosition === -1) { 9 | continue; 10 | } 11 | const key = line.slice(0, splitPosition); 12 | const value = line.slice(splitPosition + 1); 13 | h[key] = value.trim(); 14 | } 15 | return h; 16 | } 17 | 18 | export function parseStatusLine(line: string): StatusLine { 19 | if (!line.startsWith('HTTP/1.1')) { 20 | throw new Error(`unexpected status line: ${line}`); 21 | } 22 | const statusLineSplit = line.split(' '); 23 | if (statusLineSplit.length < 3) { 24 | throw new Error(`unexpected status line: ${line}`); 25 | } 26 | const status = parseInt(statusLineSplit[1]); 27 | const statusText = statusLineSplit.slice(2).join(' '); 28 | return { status, statusText }; 29 | } -------------------------------------------------------------------------------- /v2/build-tools/man/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "os" 6 | 7 | "github.com/forestnode-io/oneshot/v2/pkg/commands/root" 8 | "github.com/spf13/cobra/doc" 9 | ) 10 | 11 | func main() { 12 | cmd := root.CobraCommand(false) 13 | header := doc.GenManHeader{ 14 | Title: "ONESHOT", 15 | Section: "1", 16 | Source: "https://github.com/forestnode-io/oneshot/v2", 17 | } 18 | if err := doc.GenManTree(cmd, &header, os.Args[1]); err != nil { 19 | log.Print(err) 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /v2/cmd/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "math/rand" 7 | "os" 8 | "os/signal" 9 | "syscall" 10 | "time" 11 | 12 | "github.com/forestnode-io/oneshot/v2/pkg/commands/root" 13 | "github.com/forestnode-io/oneshot/v2/pkg/events" 14 | "github.com/forestnode-io/oneshot/v2/pkg/log" 15 | "github.com/forestnode-io/oneshot/v2/pkg/net/webrtc/signallingserver" 16 | "github.com/forestnode-io/oneshot/v2/pkg/output" 17 | "github.com/forestnode-io/oneshot/v2/pkg/sys" 18 | ) 19 | 20 | func main() { 21 | var ( 22 | status = events.ExitCodeGenericFailure 23 | err error 24 | ) 25 | 26 | //lint:ignore SA1019 the issues that plague this implementation are not relevant to this project 27 | rand.Seed(time.Now().UnixNano()) 28 | 29 | ctx, cleanupLogging, err := log.Logging(context.Background()) 30 | if err != nil { 31 | panic(err) 32 | } 33 | defer cleanupLogging() 34 | 35 | ctx = events.WithEvents(ctx) 36 | ctx, err = output.WithOutput(ctx) 37 | if err != nil { 38 | fmt.Printf("error setting up output: %s\n", err.Error()) 39 | return 40 | } 41 | 42 | defer func() { 43 | output.RestoreCursor(ctx) 44 | if r := recover(); r != nil { 45 | panic(r) 46 | } else { 47 | if ec := events.GetExitCode(ctx); -1 < ec { 48 | status = ec 49 | } 50 | os.Exit(status) 51 | } 52 | }() 53 | 54 | var discoveryServerConnDoneChan <-chan struct{} 55 | ctx, discoveryServerConnDoneChan = signallingserver.WithDiscoveryServer(ctx) 56 | // wait for the discovery server to connection to be done 57 | // or timeout after 1 second 58 | defer func() { 59 | timeout := time.NewTimer(time.Second) 60 | select { 61 | case <-discoveryServerConnDoneChan: 62 | timeout.Stop() 63 | case <-timeout.C: 64 | } 65 | }() 66 | 67 | sigs := []os.Signal{ 68 | os.Interrupt, 69 | os.Kill, 70 | } 71 | if sys.RunningOnUNIX() { 72 | sigs = append(sigs, syscall.SIGINT, syscall.SIGHUP) 73 | } 74 | ctx, cancel := signal.NotifyContext(ctx, sigs...) 75 | defer cancel() 76 | 77 | if err := root.ExecuteContext(ctx); err == nil { 78 | status = events.ExitCodeSuccess 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /v2/install.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # error codes 4 | # 0 - exited without problems 5 | # 1 - parameters not supported were used or some unexpected error occurred 6 | # 2 - OS not supported by this script 7 | # 3 - installed version of oneshot is up to date 8 | 9 | set -e 10 | 11 | usage() { echo "Usage: curl https://github.com/forestnode-io/oneshot/raw/v2/v2/install.sh | sudo bash " 1>&2; exit 1; } 12 | 13 | #create tmp directory and move to it with macOS compatibility fallback 14 | tmp_dir=`mktemp -d 2>/dev/null || mktemp -d -t 'oneshot-install.XXXXXXXXXX'`; cd $tmp_dir 15 | 16 | #check installed version of oneshot to determine if update is necessary 17 | version=`oneshot -v 2>>errors | head -n 1 | awk '{print $4}'` 18 | current_version=`curl -s -L https://github.com/forestnode-io/oneshot/raw/v2/v2/version.txt | tr -d "v"` 19 | if [ "$version" = "$current_version" ]; then 20 | printf "\nThe latest version of oneshot ${version} is already installed.\n\n" 21 | exit 3 22 | fi 23 | 24 | #detect the platform 25 | OS="`uname`" 26 | case $OS in 27 | Linux) 28 | OS='Linux' 29 | ;; 30 | Darwin) 31 | OS='Darwin' 32 | ;; 33 | *) 34 | echo 'OS not supported' 35 | exit 2 36 | ;; 37 | esac 38 | 39 | ARCH_TYPE="`uname -m`" 40 | case $ARCH_TYPE in 41 | x86_64|amd64) 42 | ARCH_TYPE='x86_64' 43 | ;; 44 | i?86|x86) 45 | ARCH_TYPE='386' 46 | ;; 47 | arm64) 48 | ARCH_TYPE='arm64' 49 | ;; 50 | arm*) 51 | ARCH_TYPE='arm' 52 | ;; 53 | aarch64) 54 | ARCH_TYPE='arm64' 55 | ;; 56 | *) 57 | echo 'OS type not supported' 58 | exit 2 59 | ;; 60 | esac 61 | 62 | #download and untar 63 | download_link="https://github.com/forestnode-io/oneshot/releases/download/v${current_version}/oneshot_${OS}_${ARCH_TYPE}.tar.gz" 64 | echo "Downloading oneshot from $download_link" 65 | oneshot_tarball="oneshot_${OS}_${ARCH_TYPE}.tar.gz" 66 | 67 | curl -s -O -L $download_link 68 | untar_dir="oneshot_untar" 69 | mkdir $untar_dir 70 | echo "Unpacking and installing oneshot" 71 | tar -xzf $oneshot_tarball -C $untar_dir 72 | cd $untar_dir 73 | 74 | #install oneshot 75 | case $OS in 76 | 'Linux') 77 | cp oneshot /usr/bin/oneshot 78 | chmod 755 /usr/bin/oneshot 79 | chown root:root /usr/bin/oneshot 80 | ;; 81 | 'Darwin') 82 | mkdir -p /usr/local/bin 83 | cp oneshot /usr/local/bin/oneshot 84 | ;; 85 | *) 86 | echo 'OS not supported' 87 | exit 2 88 | esac 89 | 90 | # Let user know oneshot was installed 91 | version=`oneshot version 2>>errors | head -n 1 | awk '{print $2}'` 92 | 93 | printf "\noneshot ${version} has successfully installed.\n" 94 | printf 'You may now run "oneshot help" for help with using oneshot.\n' 95 | printf 'Visit https://github.com/forestnode-io/oneshot for more information.\n\n' 96 | exit 0 97 | -------------------------------------------------------------------------------- /v2/integration_testing/root/root_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "syscall" 7 | "testing" 8 | "time" 9 | 10 | itest "github.com/forestnode-io/oneshot/v2/integration_testing" 11 | "github.com/stretchr/testify/suite" 12 | ) 13 | 14 | func TestBasicTestSuite(t *testing.T) { 15 | suite.Run(t, new(ts)) 16 | } 17 | 18 | type ts struct { 19 | itest.TestSuite 20 | } 21 | 22 | func (suite *ts) Test_Signal_SIGINT() { 23 | var oneshot = suite.NewOneshot() 24 | oneshot.Args = []string{"receive"} 25 | oneshot.Start() 26 | defer oneshot.Cleanup() 27 | 28 | time.Sleep(500 * time.Millisecond) 29 | err := oneshot.Cmd.Process.Signal(syscall.SIGINT) 30 | suite.Require().NoError(err) 31 | 32 | oneshot.Wait() 33 | } 34 | 35 | func (suite *ts) Test_timeoutFlag() { 36 | var oneshot = suite.NewOneshot() 37 | oneshot.Args = []string{"receive", "--timeout", "1s"} 38 | oneshot.Start() 39 | defer oneshot.Cleanup() 40 | 41 | timer := time.AfterFunc(time.Second+500*time.Millisecond, func() { 42 | _ = oneshot.Cmd.Process.Signal(syscall.SIGINT) 43 | suite.Fail("timeout did not work") 44 | }) 45 | defer timer.Stop() 46 | 47 | oneshot.Wait() 48 | } 49 | 50 | func (suite *ts) Test_Basic_Auth() { 51 | var oneshot = suite.NewOneshot() 52 | oneshot.Args = []string{"send", "--username", "oneshot", "--password", "hunter2"} 53 | oneshot.Stdin = itest.EOFReader([]byte("SUCCESS")) 54 | oneshot.Env = []string{ 55 | "ONESHOT_TESTING_TTY_STDIN=true", 56 | "ONESHOT_TESTING_TTY_STDOUT=true", 57 | "ONESHOT_TESTING_TTY_STDERR=true", 58 | } 59 | oneshot.Start() 60 | defer oneshot.Cleanup() 61 | 62 | client := itest.RetryClient{} 63 | resp, err := client.Get("http://127.0.0.1:" + oneshot.Port) 64 | suite.Require().NoError(err) 65 | suite.Assert().Equal(resp.StatusCode, http.StatusUnauthorized) 66 | 67 | req, err := http.NewRequest("GET", "http://127.0.0.1:"+oneshot.Port, nil) 68 | suite.Require().NoError(err) 69 | req.SetBasicAuth("oneshot", "hunter2") 70 | resp, err = client.Do(req) 71 | suite.Require().NoError(err) 72 | suite.Assert().Equal(resp.StatusCode, http.StatusOK) 73 | 74 | body, err := io.ReadAll(resp.Body) 75 | suite.Assert().NoError(err) 76 | resp.Body.Close() 77 | suite.Assert().Equal(string(body), "SUCCESS") 78 | 79 | oneshot.Wait() 80 | } 81 | -------------------------------------------------------------------------------- /v2/integration_testing/version/version_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "testing" 7 | 8 | itest "github.com/forestnode-io/oneshot/v2/integration_testing" 9 | "github.com/stretchr/testify/suite" 10 | ) 11 | 12 | func TestBasicTestSuite(t *testing.T) { 13 | suite.Run(t, new(ts)) 14 | } 15 | 16 | type ts struct { 17 | itest.TestSuite 18 | } 19 | 20 | func (suite *ts) Test_NoHang() { 21 | var oneshot = suite.NewOneshot() 22 | oneshot.Args = []string{"version"} 23 | oneshot.Start() 24 | defer oneshot.Cleanup() 25 | 26 | oneshot.Wait() 27 | } 28 | 29 | func (suite *ts) Test_NoError() { 30 | var oneshot = suite.NewOneshot() 31 | oneshot.Args = []string{"version"} 32 | oneshot.Start() 33 | defer oneshot.Cleanup() 34 | 35 | oneshot.Wait() 36 | suite.Assert().True(oneshot.Cmd.ProcessState.Success()) 37 | } 38 | 39 | func (suite *ts) Test_JSON() { 40 | var ( 41 | oneshot = suite.NewOneshot() 42 | stdout = bytes.NewBuffer(nil) 43 | ) 44 | oneshot.Args = []string{"version", "--output=json"} 45 | oneshot.Stdout = stdout 46 | oneshot.Start() 47 | defer oneshot.Cleanup() 48 | 49 | oneshot.Wait() 50 | suite.Assert().True(oneshot.Cmd.ProcessState.Success()) 51 | 52 | var output struct { 53 | APIVersion string `json:"apiVersion"` 54 | Version string `json:"version"` 55 | License string `json:"license"` 56 | } 57 | 58 | err := json.NewDecoder(stdout).Decode(&output) 59 | suite.Require().NoError(err) 60 | 61 | suite.Assert().NotEmpty(output.APIVersion) 62 | suite.Assert().NotEmpty(output.Version) 63 | suite.Assert().NotEmpty(output.License) 64 | } 65 | -------------------------------------------------------------------------------- /v2/pkg/cgi/cgi.go: -------------------------------------------------------------------------------- 1 | package cgi 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "log" 8 | "net/http" 9 | "os" 10 | "os/exec" 11 | "strings" 12 | ) 13 | 14 | type OutputHandler func(w http.ResponseWriter, r *http.Request, h *Handler, stdoutReader io.Reader) 15 | 16 | type HandlerConfig struct { 17 | Cmd []string 18 | WorkingDir string 19 | InheritEnvs []string 20 | BaseEnv []string 21 | Header http.Header 22 | 23 | OutputHandler OutputHandler 24 | Stderr io.Writer 25 | } 26 | 27 | type Handler struct { 28 | execPath string 29 | args []string 30 | workingDir string 31 | 32 | env []string 33 | header http.Header 34 | 35 | outputHandler OutputHandler 36 | 37 | stderr io.Writer 38 | } 39 | 40 | func NewHandler(conf HandlerConfig) (*Handler, error) { 41 | var ( 42 | l = len(conf.Cmd) 43 | h = Handler{ 44 | workingDir: conf.WorkingDir, 45 | header: conf.Header, 46 | env: NewEnv(conf.BaseEnv, conf.InheritEnvs), 47 | outputHandler: conf.OutputHandler, 48 | stderr: conf.Stderr, 49 | } 50 | ) 51 | 52 | if l == 0 { 53 | return nil, errors.New("command required") 54 | } 55 | h.execPath = findExec(conf.Cmd[0]) 56 | 57 | if 1 < l { 58 | h.args = conf.Cmd[1:] 59 | } 60 | 61 | if h.header == nil { 62 | h.header = http.Header{ 63 | "Content-Type": []string{"text/plain"}, 64 | } 65 | } 66 | 67 | if h.workingDir == "" { 68 | var err error 69 | if h.workingDir, err = os.Getwd(); err != nil { 70 | return nil, err 71 | } 72 | } 73 | 74 | if h.outputHandler == nil { 75 | h.outputHandler = DefaultOutputHandler 76 | } 77 | 78 | if h.stderr == nil { 79 | h.stderr = io.Discard 80 | } 81 | 82 | return &h, nil 83 | } 84 | 85 | func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 86 | if len(r.TransferEncoding) > 0 && r.TransferEncoding[0] == "chunked" { 87 | w.WriteHeader(http.StatusBadRequest) 88 | _, _ = w.Write([]byte("Chunked request bodies are not supported by CGI.")) 89 | return 90 | } 91 | 92 | internalError := func(err error) { 93 | fmt.Fprintf(h.stderr, "internal server error: %v", err) 94 | http.Error(w, err.Error(), http.StatusInternalServerError) 95 | } 96 | 97 | cmd := &exec.Cmd{ 98 | Path: h.execPath, 99 | Args: append([]string{h.execPath}, h.args...), 100 | Dir: h.workingDir, 101 | Env: AddRequest(h.env, r), 102 | Stderr: h.stderr, 103 | } 104 | 105 | if r.ContentLength != 0 { 106 | cmd.Stdin = r.Body 107 | } 108 | stdoutRead, err := cmd.StdoutPipe() 109 | if err != nil { 110 | internalError(fmt.Errorf("unable to get cmd stdout pipe: %w", err)) 111 | return 112 | } 113 | err = cmd.Start() 114 | if err != nil { 115 | internalError(fmt.Errorf("unable to get start cmd: %w", err)) 116 | return 117 | } 118 | 119 | defer func() { 120 | if err := cmd.Wait(); err != nil { 121 | internalError(fmt.Errorf("cmd failed %s: %w", cmd.Path+strings.Join(cmd.Args, " "), err)) 122 | } 123 | }() 124 | defer stdoutRead.Close() 125 | 126 | h.outputHandler(w, r, h, stdoutRead) 127 | 128 | // Make sure the process is good and dead before exiting 129 | if err := cmd.Process.Kill(); err != nil { 130 | log.Printf("unable to kill process %d: %v", cmd.Process.Pid, err) 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /v2/pkg/cgi/exec.go: -------------------------------------------------------------------------------- 1 | package cgi 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strings" 7 | ) 8 | 9 | func findExec(name string) string { 10 | if name != filepath.Base(name) { 11 | if err := isExec(name); err == nil { 12 | return name 13 | } 14 | } else { 15 | paths := strings.Split(os.Getenv("PATH"), ":") 16 | for _, pathDir := range paths { 17 | execPath := filepath.Join(pathDir, name) 18 | if err := isExec(execPath); err == nil { 19 | return execPath 20 | } 21 | } 22 | } 23 | 24 | return "" 25 | } 26 | -------------------------------------------------------------------------------- /v2/pkg/cgi/isExec_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package cgi 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | "os/user" 9 | "syscall" 10 | ) 11 | 12 | func isExec(path string) error { 13 | const ( 14 | bmOthers = 0x0001 15 | bmGroup = 0x0010 16 | bmOwner = 0x0100 17 | ) 18 | 19 | info, err := os.Stat(path) 20 | if err != nil { 21 | return err 22 | } 23 | mode := info.Mode() 24 | 25 | // check if executable by others 26 | if mode&bmOthers != 0 { 27 | return nil 28 | } 29 | 30 | stat := info.Sys().(*syscall.Stat_t) 31 | usr, err := user.Current() 32 | if err != nil { 33 | return err 34 | } 35 | 36 | // check if executable by group 37 | if mode&bmGroup != 0 { 38 | gid := fmt.Sprint(stat.Gid) 39 | gids, err := usr.GroupIds() 40 | if err != nil { 41 | return err 42 | } 43 | for _, g := range gids { 44 | if g == gid { 45 | return nil 46 | } 47 | } 48 | } 49 | 50 | // check if exec by owner 51 | if mode&bmOwner != 0 { 52 | uid := fmt.Sprint(stat.Uid) 53 | if uid == usr.Uid { 54 | return nil 55 | } 56 | } 57 | 58 | return fmt.Errorf("%s: permission denied", path) 59 | } 60 | -------------------------------------------------------------------------------- /v2/pkg/cgi/isExec_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package cgi 4 | 5 | import ( 6 | "fmt" 7 | "path/filepath" 8 | ) 9 | 10 | func isExec(path string) error { 11 | if filepath.Ext(path) == ".exe" { 12 | return nil 13 | } 14 | 15 | return fmt.Errorf("%s must be executable", path) 16 | } 17 | -------------------------------------------------------------------------------- /v2/pkg/commands/closers.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "context" 5 | "io" 6 | ) 7 | 8 | type closerKey struct{} 9 | 10 | func WithClosers(ctx context.Context, closers *[]io.Closer) context.Context { 11 | return context.WithValue(ctx, closerKey{}, closers) 12 | } 13 | 14 | func MarkForClose(ctx context.Context, closer io.Closer) { 15 | if closers, ok := ctx.Value(closerKey{}).(*[]io.Closer); ok { 16 | *closers = append(*closers, closer) 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /v2/pkg/commands/config/config.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | import ( 4 | "github.com/forestnode-io/oneshot/v2/pkg/commands/config/get" 5 | "github.com/forestnode-io/oneshot/v2/pkg/commands/config/path" 6 | "github.com/forestnode-io/oneshot/v2/pkg/commands/config/set" 7 | "github.com/forestnode-io/oneshot/v2/pkg/configuration" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func New(config *configuration.Root) *Cmd { 12 | return &Cmd{ 13 | config: config, 14 | } 15 | } 16 | 17 | type Cmd struct { 18 | cobraCommand *cobra.Command 19 | config *configuration.Root 20 | } 21 | 22 | func (c *Cmd) Cobra() *cobra.Command { 23 | if c.cobraCommand != nil { 24 | return c.cobraCommand 25 | } 26 | 27 | c.cobraCommand = &cobra.Command{ 28 | Use: "config", 29 | Aliases: []string{"conf, configuration"}, 30 | Short: "Modify oneshot configuration files.", 31 | Long: "Modify oneshot configuration files.", 32 | } 33 | 34 | c.cobraCommand.SetUsageTemplate(usageTemplate) 35 | 36 | c.cobraCommand.AddCommand(subCommands(c.config)...) 37 | 38 | return c.cobraCommand 39 | } 40 | 41 | func subCommands(config *configuration.Root) []*cobra.Command { 42 | return []*cobra.Command{ 43 | get.New(config).Cobra(), 44 | set.New(config).Cobra(), 45 | path.New().Cobra(), 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /v2/pkg/commands/config/get/cobra.go: -------------------------------------------------------------------------------- 1 | package get 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/forestnode-io/oneshot/v2/pkg/configuration" 8 | "github.com/forestnode-io/oneshot/v2/pkg/output" 9 | "github.com/spf13/cobra" 10 | "github.com/spf13/viper" 11 | "gopkg.in/yaml.v3" 12 | ) 13 | 14 | func New(config *configuration.Root) *Cmd { 15 | return &Cmd{ 16 | config: config, 17 | } 18 | } 19 | 20 | type Cmd struct { 21 | cobraCommand *cobra.Command 22 | config *configuration.Root 23 | } 24 | 25 | func (c *Cmd) Cobra() *cobra.Command { 26 | if c.cobraCommand != nil { 27 | return c.cobraCommand 28 | } 29 | 30 | c.cobraCommand = &cobra.Command{ 31 | Use: "get path", 32 | Short: "Get an individual value from a oneshot configuration file.", 33 | Long: "Get an individual value from a oneshot configuration file.", 34 | RunE: c.run, 35 | Args: cobra.MaximumNArgs(1), 36 | } 37 | 38 | c.cobraCommand.SetUsageTemplate(usageTemplate) 39 | 40 | return c.cobraCommand 41 | } 42 | 43 | func (c *Cmd) run(cmd *cobra.Command, args []string) error { 44 | m := map[string]any{} 45 | err := viper.Unmarshal(&m) 46 | if err != nil { 47 | return fmt.Errorf("failed to unmarshal configuration: %w", err) 48 | } 49 | 50 | dc, ok := m["discovery"].(map[string]any) 51 | if !ok { 52 | return fmt.Errorf("failed to get discovery configuration") 53 | } 54 | 55 | key, ok := dc["key"].(string) 56 | if !ok { 57 | return fmt.Errorf("failed to get discovery key") 58 | } 59 | 60 | if key != "" { 61 | if 8 < len(key) { 62 | key = key[:8] + "..." 63 | } else { 64 | key = "********" 65 | } 66 | viper.Set("discovery.key", key) 67 | } 68 | dc["key"] = key 69 | m["discovery"] = dc 70 | 71 | v := viper.New() 72 | v.MergeConfigMap(m) 73 | 74 | if len(args) == 0 || args[0] == "" { 75 | return yaml.NewEncoder(os.Stdout).Encode(v.AllSettings()) 76 | } 77 | 78 | configData := v.Get(args[0]) 79 | if configData == nil { 80 | return output.UsageErrorF("no such key: %s", args[0]) 81 | } 82 | 83 | return yaml.NewEncoder(os.Stdout).Encode(configData) 84 | } 85 | -------------------------------------------------------------------------------- /v2/pkg/commands/config/get/usage.go: -------------------------------------------------------------------------------- 1 | package get 2 | 3 | const usageTemplate = `get options: 4 | {{ .LocalFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 5 | 6 | Usage: 7 | {{ .UseLine }} 8 | ` 9 | -------------------------------------------------------------------------------- /v2/pkg/commands/config/path/cobra.go: -------------------------------------------------------------------------------- 1 | package path 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/forestnode-io/oneshot/v2/pkg/configuration" 7 | "github.com/forestnode-io/oneshot/v2/pkg/output" 8 | "github.com/spf13/cobra" 9 | ) 10 | 11 | func New() *Cmd { 12 | return &Cmd{} 13 | } 14 | 15 | type Cmd struct { 16 | cobraCommand *cobra.Command 17 | } 18 | 19 | func (c *Cmd) Cobra() *cobra.Command { 20 | if c.cobraCommand != nil { 21 | return c.cobraCommand 22 | } 23 | 24 | c.cobraCommand = &cobra.Command{ 25 | Use: "path", 26 | Aliases: []string{"location", "file"}, 27 | Short: "Get the path to the oneshot configuration file being used.", 28 | Long: "Get the path to the oneshot configuration file being used.", 29 | RunE: c.run, 30 | Args: cobra.NoArgs, 31 | } 32 | 33 | c.cobraCommand.SetUsageTemplate(usageTemplate) 34 | 35 | return c.cobraCommand 36 | } 37 | 38 | func (c *Cmd) run(cmd *cobra.Command, args []string) error { 39 | if configuration.ConfigPath() != "" { 40 | fmt.Printf("%s\n", configuration.ConfigPath()) 41 | } else { 42 | return output.UsageErrorF("no configuration file found") 43 | } 44 | return nil 45 | } 46 | -------------------------------------------------------------------------------- /v2/pkg/commands/config/path/usage.go: -------------------------------------------------------------------------------- 1 | package path 2 | 3 | const usageTemplate = `path options: 4 | {{ .LocalFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 5 | 6 | Usage: 7 | {{ .UseLine }} 8 | ` 9 | -------------------------------------------------------------------------------- /v2/pkg/commands/config/set/cobra.go: -------------------------------------------------------------------------------- 1 | package set 2 | 3 | import ( 4 | "strconv" 5 | "strings" 6 | 7 | "github.com/forestnode-io/oneshot/v2/pkg/configuration" 8 | "github.com/forestnode-io/oneshot/v2/pkg/output" 9 | "github.com/spf13/cobra" 10 | "github.com/spf13/viper" 11 | ) 12 | 13 | func New(config *configuration.Root) *Cmd { 14 | return &Cmd{ 15 | config: config, 16 | } 17 | } 18 | 19 | type Cmd struct { 20 | cobraCommand *cobra.Command 21 | config *configuration.Root 22 | } 23 | 24 | func (c *Cmd) Cobra() *cobra.Command { 25 | if c.cobraCommand != nil { 26 | return c.cobraCommand 27 | } 28 | 29 | c.cobraCommand = &cobra.Command{ 30 | Use: "set path value...", 31 | Short: "Set an individual value in a oneshot configuration file.", 32 | Long: "Set an individual value in a oneshot configuration file.", 33 | RunE: c.run, 34 | Args: cobra.MinimumNArgs(2), 35 | } 36 | 37 | c.cobraCommand.SetUsageTemplate(usageTemplate) 38 | 39 | return c.cobraCommand 40 | } 41 | 42 | func (c *Cmd) run(cmd *cobra.Command, args []string) error { 43 | if configuration.ConfigPath() == "" { 44 | return output.UsageErrorF("no configuration file found") 45 | } 46 | 47 | v := viper.Get(args[0]) 48 | if v == nil { 49 | return output.UsageErrorF("no such key: %s", args[0]) 50 | } 51 | switch v.(type) { 52 | case string: 53 | viper.Set(args[0], args[1]) 54 | case int: 55 | x, err := strconv.Atoi(args[1]) 56 | if err != nil { 57 | return output.UsageErrorF("failed to convert value to int: %w", err) 58 | } 59 | viper.Set(args[0], x) 60 | case bool: 61 | x, err := strconv.ParseBool(args[1]) 62 | if err != nil { 63 | return output.UsageErrorF("failed to convert value to bool: %w", err) 64 | } 65 | viper.Set(args[0], x) 66 | case []string: 67 | if 3 <= len(args) { 68 | viper.Set(args[0], args[1:]) 69 | } else { 70 | viper.Set(args[0], strings.Split(args[1], ",")) 71 | } 72 | case []int: 73 | if 3 <= len(args) { 74 | ints := make([]int, len(args)-1) 75 | var err error 76 | for i := range args[1:] { 77 | ints[i], err = strconv.Atoi(args[i+1]) 78 | if err != nil { 79 | return output.UsageErrorF("failed to convert value to int: %w", err) 80 | } 81 | } 82 | viper.Set(args[0], ints) 83 | } 84 | default: 85 | return output.UsageErrorF("unsupported type: %T", v) 86 | } 87 | 88 | err := viper.WriteConfig() 89 | if err != nil { 90 | return output.UsageErrorF("failed to write configuration file: %w", err) 91 | } 92 | 93 | return nil 94 | } 95 | -------------------------------------------------------------------------------- /v2/pkg/commands/config/set/usage.go: -------------------------------------------------------------------------------- 1 | package set 2 | 3 | const usageTemplate = `set options: 4 | {{ .LocalFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 5 | 6 | Usage: 7 | {{ .UseLine }} 8 | ` 9 | -------------------------------------------------------------------------------- /v2/pkg/commands/config/usage.go: -------------------------------------------------------------------------------- 1 | package config 2 | 3 | const usageTemplate = `Usage: 4 | {{ .CommandPath }} [command] 5 | 6 | Available Commands: {{ range .Commands }}{{if (or .IsAvailableCommand (eq .Name "help"))}} 7 | {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}} 8 | ` 9 | -------------------------------------------------------------------------------- /v2/pkg/commands/discovery-server/cobra.go: -------------------------------------------------------------------------------- 1 | package discoveryserver 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/forestnode-io/oneshot/v2/pkg/configuration" 7 | oneshotnet "github.com/forestnode-io/oneshot/v2/pkg/net" 8 | "github.com/pion/webrtc/v3" 9 | "github.com/rs/zerolog" 10 | "github.com/spf13/cobra" 11 | ) 12 | 13 | type Cmd struct { 14 | cobraCommand *cobra.Command 15 | config *configuration.Root 16 | } 17 | 18 | func New(config *configuration.Root) *Cmd { 19 | return &Cmd{ 20 | config: config, 21 | } 22 | } 23 | 24 | func (c *Cmd) Cobra() *cobra.Command { 25 | if c.cobraCommand != nil { 26 | return c.cobraCommand 27 | } 28 | 29 | c.cobraCommand = &cobra.Command{ 30 | Use: "discovery-server", 31 | Short: "A NAT traversal discovery server", 32 | Long: `A NAT traversal discovery server. 33 | If using UPnP-IGD NAT traversal, the discovery server will redirect traffic to the public ip + newly opened external port. 34 | This allows for a dynamic DNS type service. 35 | If using P2P NAT traversal, the discovery server will act as the signalling server for the peers to establish a connection. 36 | The discovery server will accept both other oneshot instances and web browsers as clients. 37 | Web browsers will be served a JS WebRTC client that will connect back to the discovery server and perform the P2P NAT traversal. 38 | `, 39 | SuggestFor: []string{ 40 | "p2p browser-client", 41 | "p2p client send", 42 | "p2p client receive", 43 | }, 44 | RunE: c.run, 45 | } 46 | 47 | c.cobraCommand.SetUsageTemplate(usageTemplate) 48 | 49 | return c.cobraCommand 50 | } 51 | 52 | func (c *Cmd) run(cmd *cobra.Command, args []string) error { 53 | var ( 54 | ctx = cmd.Context() 55 | log = zerolog.Ctx(ctx) 56 | uaConf = c.config.Subcommands.DiscoveryServer.URLAssignment 57 | sConf = c.config.Server 58 | ) 59 | 60 | if uaConf.Scheme == "" { 61 | if sConf.TLSCert != "" && sConf.TLSKey != "" { 62 | uaConf.Scheme = "https" 63 | } else { 64 | uaConf.Scheme = "http" 65 | } 66 | } 67 | if uaConf.Domain == "" { 68 | uaConf.Domain = sConf.Host 69 | if uaConf.Domain == "" { 70 | sip, err := oneshotnet.GetSourceIP("", 80) 71 | if err != nil { 72 | return fmt.Errorf("unable to get source ip: %w", err) 73 | } 74 | uaConf.Domain = sip.String() 75 | } 76 | } 77 | if uaConf.Port == 0 { 78 | uaConf.Port = sConf.Port 79 | } 80 | if uaConf.Path == "" { 81 | uaConf.Path = "/" 82 | } 83 | 84 | s, err := newServer(c.config) 85 | if err != nil { 86 | return fmt.Errorf("unable to create signalling server: %w", err) 87 | } 88 | if err := s.run(ctx); err != nil { 89 | log.Error().Err(err). 90 | Msg("error running server") 91 | } 92 | 93 | log.Info().Msg("discovery server exiting") 94 | 95 | return nil 96 | } 97 | 98 | type ClientOfferRequestResponse struct { 99 | RTCSessionDescription *webrtc.SessionDescription `json:"RTCSessionDescription"` 100 | RTCConfiguration *webrtc.Configuration `json:"RTCConfiguration"` 101 | SessionID string `json:"SessionID"` 102 | } 103 | -------------------------------------------------------------------------------- /v2/pkg/commands/discovery-server/configuration/configuration.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | ) 7 | 8 | type Configuration struct { 9 | RequiredKey *Secret `mapstructure:"requiredkey" yaml:"requiredkey"` 10 | JWT *Secret `mapstructure:"jwt" yaml:"jwt"` 11 | MaxClientQueueSize int `mapstructure:"maxqueuesize" yaml:"maxqueuesize"` 12 | URLAssignment *URLAssignment `mapstructure:"urlassignment" yaml:"urlassignment"` 13 | APIServer *Server `mapstructure:"server" yaml:"server"` 14 | } 15 | 16 | func (c *Configuration) Validate() error { 17 | if err := c.URLAssignment.validate(); err != nil { 18 | return fmt.Errorf("invalid URL assignment: %w", err) 19 | } 20 | if err := c.APIServer.validate(); err != nil { 21 | return fmt.Errorf("invalid API server: %w", err) 22 | } 23 | return nil 24 | } 25 | 26 | func (c *Configuration) Hydrate() error { 27 | if err := c.RequiredKey.hydrate(); err != nil { 28 | return fmt.Errorf("failed to hydrate required key: %w", err) 29 | } 30 | if err := c.JWT.hydrate(); err != nil { 31 | return fmt.Errorf("failed to hydrate JWT: %w", err) 32 | } 33 | return nil 34 | } 35 | 36 | type Secret struct { 37 | Path string `mapstructure:"path" yaml:"path"` 38 | Value string `mapstructure:"value" yaml:"value"` 39 | } 40 | 41 | func (s *Secret) hydrate() error { 42 | if s.Value != "" { 43 | return nil 44 | } 45 | if s.Path == "" { 46 | return nil 47 | } 48 | 49 | data, err := os.ReadFile(s.Path) 50 | if err != nil { 51 | return fmt.Errorf("failed to read secret from file: %w", err) 52 | } 53 | 54 | s.Value = string(data) 55 | 56 | return nil 57 | } 58 | 59 | type URLAssignment struct { 60 | Scheme string `mapstructure:"scheme" yaml:"scheme"` 61 | Domain string `mapstructure:"domain" yaml:"domain"` 62 | Port int `mapstructure:"port" yaml:"port"` 63 | Path string `mapstructure:"path" yaml:"path"` 64 | PathPrefix string `mapstructure:"pathprefix" yaml:"pathprefix"` 65 | } 66 | 67 | func (c *URLAssignment) validate() error { 68 | return nil 69 | } 70 | 71 | type Server struct { 72 | Addr string `mapstructure:"addr" yaml:"addr"` 73 | TLSCert string `mapstructure:"tlscert" yaml:"tlscert"` 74 | TLSKey string `mapstructure:"tlskey" yaml:"tlskey"` 75 | } 76 | 77 | func (c *Server) validate() error { 78 | if c.TLSCert != "" && c.TLSKey != "" { 79 | if c.TLSCert == "" { 80 | return fmt.Errorf("tls cert path is empty") 81 | } 82 | if c.TLSKey == "" { 83 | return fmt.Errorf("tls key path is empty") 84 | } 85 | } 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /v2/pkg/commands/discovery-server/template/template.go: -------------------------------------------------------------------------------- 1 | package template 2 | 3 | import ( 4 | "embed" 5 | "html/template" 6 | "io" 7 | "strings" 8 | ) 9 | 10 | //go:generate make webrtc-client 11 | var ( 12 | //go:embed webrtc-client.js 13 | ClientJS template.JS 14 | 15 | //go:embed sd-streams-polyfill.min.js 16 | PolyfillJS template.JS 17 | 18 | indentFunc = func(spaces int, v template.HTML) template.HTML { 19 | if v == "" { 20 | return v 21 | } 22 | vs := string(v) 23 | pad := strings.Repeat(" ", spaces) 24 | return template.HTML(pad + strings.Replace(vs, "\n", "\n"+pad, -1) + "\n") 25 | } 26 | 27 | //go:embed templates/*.html 28 | tmpltFS embed.FS 29 | tmplt = template.Must( 30 | template.New("root"). 31 | Funcs(template.FuncMap{ 32 | "indent": indentFunc, 33 | }). 34 | ParseFS(tmpltFS, "templates/*.html"), 35 | ) 36 | ) 37 | 38 | func init() { 39 | if len(ClientJS) == 0 { 40 | panic("browserClientJS is empty") 41 | } 42 | 43 | if tmplt == nil { 44 | panic("tmplt is nil") 45 | } 46 | } 47 | 48 | type Context struct { 49 | AutoConnect bool 50 | 51 | RTCConfigJSON string 52 | OfferJSON string 53 | 54 | Head template.HTML 55 | ClientJS template.JS 56 | PolyfillJS template.JS 57 | } 58 | 59 | type errCtx struct { 60 | ErrorTitle string 61 | ErrorDescription string 62 | Title string 63 | } 64 | 65 | func WriteTo(w io.Writer, ctx Context) error { 66 | return tmplt.ExecuteTemplate(w, "index", ctx) 67 | } 68 | 69 | func Error(w io.Writer, errTitle, errDescription, pageTitle string) error { 70 | return tmplt.ExecuteTemplate(w, "error", errCtx{ 71 | ErrorTitle: errTitle, 72 | ErrorDescription: errDescription, 73 | Title: pageTitle, 74 | }) 75 | } 76 | -------------------------------------------------------------------------------- /v2/pkg/commands/discovery-server/template/templates/auto-answer.html: -------------------------------------------------------------------------------- 1 | {{ define "auto-answer" }} 2 | 77 | {{ end }} -------------------------------------------------------------------------------- /v2/pkg/commands/discovery-server/template/templates/error.html: -------------------------------------------------------------------------------- 1 | {{/* 2 | { 3 | Title string 4 | ErrorTitle string 5 | ErrorDescription string 6 | } 7 | */}} 8 | {{- define "error" -}} 9 | 10 | 11 | 12 | {{ if .Title }}{{ .Title }}{{ end }} 13 | 14 | 15 |

{{ .ErrorTitle }}

16 |

{{ .ErrorDescription }}

17 | 18 | 19 | {{- end -}} -------------------------------------------------------------------------------- /v2/pkg/commands/discovery-server/template/templates/index.html: -------------------------------------------------------------------------------- 1 | {{/* 2 | { 3 | AutoConnect bool 4 | ClientJS template.HTML 5 | PolyfillJS template.HTML 6 | 7 | RTCConfigJSON string 8 | OfferJSON string 9 | Endpoint string 10 | BasicAuthToken string // optional 11 | SessionToken string // optional 12 | 13 | Head template.HTML 14 | } 15 | */}} 16 | {{- define "index" -}} 17 | 18 | 19 | 20 | {{ indent 4 .Head -}} 21 | 24 | 27 | 28 | 29 | {{- if .AutoConnect -}} 30 | {{- template "auto-answer" . -}} 31 | {{- else -}} 32 | {{- template "manual-answer" . -}} 33 | {{- end -}} 34 | 35 | 36 | {{- end -}} 37 | 38 | {{- template "index" . -}} -------------------------------------------------------------------------------- /v2/pkg/commands/discovery-server/usage.go: -------------------------------------------------------------------------------- 1 | package discoveryserver 2 | 3 | const usageTemplate = `Output options: 4 | {{ outputFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 5 | 6 | Server options: 7 | {{ serverFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 8 | 9 | Basic Authentication options: 10 | {{ basicAuthFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 11 | 12 | CORS options: 13 | {{ corsFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 14 | 15 | NAT Traversal options: 16 | {{ "P2P options:" | indent 2 }} 17 | {{ "--p2p-webrtc-config-file string Path to the configuration file for the underlying WebRTC transport." | indent 4 }} 18 | 19 | Usage: 20 | {{.UseLine}} 21 | ` 22 | -------------------------------------------------------------------------------- /v2/pkg/commands/exec/configuration/configuration.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | 7 | "github.com/forestnode-io/oneshot/v2/pkg/flagargs" 8 | "github.com/forestnode-io/oneshot/v2/pkg/flags" 9 | "github.com/spf13/cobra" 10 | "github.com/spf13/pflag" 11 | ) 12 | 13 | type Configuration struct { 14 | EnforceCGI bool `mapstructure:"enforcecgi" yaml:"enforcecgi"` 15 | Env []string `mapstructure:"env" yaml:"env"` 16 | Dir string `mapstructure:"dir" yaml:"dir"` 17 | StdErr string `mapstructure:"stderr" yaml:"stderr"` 18 | ReplaceHeaders bool `mapstructure:"replaceheaders" yaml:"replaceheaders"` 19 | Header flagargs.HTTPHeader `mapstructure:"headers" yaml:"headers"` 20 | } 21 | 22 | func SetFlags(cmd *cobra.Command) { 23 | fs := pflag.NewFlagSet("exec flags", pflag.ContinueOnError) 24 | defer cmd.Flags().AddFlagSet(fs) 25 | 26 | flags.Bool(fs, "cmd.exec.enforcecgi", "enforce-cgi", "The exec must conform to the CGI standard.") 27 | flags.StringSliceP(fs, "cmd.exec.env", "env", "e", "Set an environment variable.") 28 | flags.String(fs, "cmd.exec.dir", "dir", "Set the working directory.") 29 | flags.String(fs, "cmd.exec.stderr", "stderr", "Where to send exec stderr.") 30 | flags.Bool(fs, "cmd.exec.replaceheaders", "replace-headers", "Allow command to replace header values.") 31 | flags.StringSliceP(fs, "cmd.exec.header", "header", "H", `Header to send to client. Can be specified multiple times. 32 | Format:
=
`) 33 | 34 | cobra.AddTemplateFunc("execFlags", func() *pflag.FlagSet { 35 | return fs 36 | }) 37 | } 38 | 39 | func (c *Configuration) Validate() error { 40 | if c.Dir != "" { 41 | stat, err := os.Stat(c.Dir) 42 | if err != nil { 43 | return fmt.Errorf("invalid directory: %w", err) 44 | } 45 | if !stat.IsDir() { 46 | return fmt.Errorf("invalid directory: %s is not a directory", c.Dir) 47 | } 48 | } 49 | return nil 50 | } 51 | 52 | func (c *Configuration) Hydrate() error { 53 | return nil 54 | } 55 | -------------------------------------------------------------------------------- /v2/pkg/commands/exec/usage.go: -------------------------------------------------------------------------------- 1 | package exec 2 | 3 | const usageTemplate = `Exec options: 4 | {{ .LocalFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 5 | 6 | Discovery options: 7 | {{ discoveryFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 8 | 9 | Output options: 10 | {{ outputFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 11 | 12 | Server options: 13 | {{ serverFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 14 | 15 | Basic Authentication options: 16 | {{ basicAuthFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 17 | 18 | CORS options: 19 | {{ corsFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 20 | 21 | NAT Traversal options: 22 | {{ "P2P options:" | indent 2 }} 23 | {{ p2pFlags | wrappedFlagUsages | trimTrailingWhitespaces | indent 4 }} 24 | {{ "Port mapping options:" | indent 2 }} 25 | {{ upnpFlags | wrappedFlagUsages | trimTrailingWhitespaces | indent 4 }} 26 | 27 | Usage: 28 | {{.UseLine}} 29 | ` 30 | -------------------------------------------------------------------------------- /v2/pkg/commands/httpHandler.go: -------------------------------------------------------------------------------- 1 | package commands 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | ) 7 | 8 | type httpHandlerKey struct{} 9 | 10 | func WithHTTPHandlerFuncSetter(ctx context.Context, h *http.HandlerFunc) context.Context { 11 | return context.WithValue(ctx, httpHandlerKey{}, h) 12 | } 13 | 14 | func SetHTTPHandlerFunc(ctx context.Context, h http.HandlerFunc) { 15 | if hp, ok := ctx.Value(httpHandlerKey{}).(*http.HandlerFunc); ok { 16 | *hp = h 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /v2/pkg/commands/p2p/browser-client/cobra.go: -------------------------------------------------------------------------------- 1 | package browserclient 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | 8 | "github.com/forestnode-io/oneshot/v2/pkg/commands/discovery-server/template" 9 | "github.com/forestnode-io/oneshot/v2/pkg/commands/p2p/browser-client/configuration" 10 | rootconfig "github.com/forestnode-io/oneshot/v2/pkg/configuration" 11 | "github.com/forestnode-io/oneshot/v2/pkg/events" 12 | "github.com/forestnode-io/oneshot/v2/pkg/output" 13 | "github.com/pion/webrtc/v3" 14 | "github.com/pkg/browser" 15 | "github.com/rs/zerolog" 16 | "github.com/spf13/cobra" 17 | ) 18 | 19 | func New(config *rootconfig.Root) *Cmd { 20 | return &Cmd{ 21 | config: config, 22 | } 23 | } 24 | 25 | type Cmd struct { 26 | cobraCommand *cobra.Command 27 | webrtcConfig *webrtc.Configuration 28 | config *rootconfig.Root 29 | } 30 | 31 | func (c *Cmd) Cobra() *cobra.Command { 32 | if c.cobraCommand != nil { 33 | return c.cobraCommand 34 | } 35 | 36 | c.cobraCommand = &cobra.Command{ 37 | Use: "browser-client", 38 | Short: "Get the p2p browser client as a single HTML file.", 39 | Long: `Get the p2p browser client as a single HTML file. 40 | This client can be used to establish a p2p connection with oneshot when not using a discovery server.`, 41 | RunE: c.run, 42 | } 43 | 44 | c.cobraCommand.SetUsageTemplate(usageTemplate) 45 | configuration.SetFlags(c.cobraCommand) 46 | 47 | return c.cobraCommand 48 | } 49 | 50 | func (c *Cmd) run(cmd *cobra.Command, args []string) error { 51 | var ( 52 | ctx = cmd.Context() 53 | log = zerolog.Ctx(ctx) 54 | config = c.config.Subcommands.P2P.BrowserClient 55 | err error 56 | ) 57 | 58 | output.InvocationInfo(ctx, cmd, args) 59 | defer func() { 60 | events.Succeeded(ctx) 61 | events.Stop(ctx) 62 | }() 63 | 64 | p2pConfig := c.config.NATTraversal.P2P 65 | iwc, err := p2pConfig.ParseConfig() 66 | if err != nil { 67 | return fmt.Errorf("failed to parse p2p configuration: %w", err) 68 | } 69 | wc, err := iwc.WebRTCConfiguration() 70 | if err != nil { 71 | return fmt.Errorf("failed to get WebRTC configuration: %w", err) 72 | } 73 | 74 | c.webrtcConfig = wc 75 | 76 | rtcConfigJSON, err := json.Marshal(c.webrtcConfig) 77 | if err != nil { 78 | return fmt.Errorf("unable to marshal webrtc config: %w", err) 79 | } 80 | 81 | tmpltCtx := template.Context{ 82 | AutoConnect: false, 83 | ClientJS: template.ClientJS, 84 | PolyfillJS: template.PolyfillJS, 85 | RTCConfigJSON: string(rtcConfigJSON), 86 | } 87 | buf := bytes.NewBuffer(nil) 88 | err = template.WriteTo(buf, tmpltCtx) 89 | if err != nil { 90 | return fmt.Errorf("unable to write template: %w", err) 91 | } 92 | 93 | if config.Open { 94 | if err := browser.OpenReader(buf); err != nil { 95 | log.Error().Err(err). 96 | Msg("failed to open browser") 97 | } 98 | } else { 99 | fmt.Print(buf.String()) 100 | } 101 | 102 | return err 103 | } 104 | -------------------------------------------------------------------------------- /v2/pkg/commands/p2p/browser-client/configuration/configuration.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "github.com/forestnode-io/oneshot/v2/pkg/flags" 5 | "github.com/spf13/cobra" 6 | "github.com/spf13/pflag" 7 | ) 8 | 9 | type Configuration struct { 10 | Open bool `json:"open" yaml:"open"` 11 | } 12 | 13 | func (c *Configuration) Validate() error { 14 | return nil 15 | } 16 | 17 | func SetFlags(cmd *cobra.Command) { 18 | fs := pflag.NewFlagSet("p2p flags", pflag.ExitOnError) 19 | defer cmd.Flags().AddFlagSet(fs) 20 | 21 | flags.BoolP(fs, "cmd.p2p.browserclient.open", "open", "o", "Open the browser to the generated URL.") 22 | 23 | cobra.AddTemplateFunc("browserClientFlags", func() *pflag.FlagSet { 24 | return fs 25 | }) 26 | } 27 | -------------------------------------------------------------------------------- /v2/pkg/commands/p2p/browser-client/usage.go: -------------------------------------------------------------------------------- 1 | package browserclient 2 | 3 | const usageTemplate = `Browser client options: 4 | {{ .LocalFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 5 | 6 | Discovery options: 7 | {{ discoveryFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 8 | 9 | NAT Traversal options: 10 | {{ "P2P options:" | indent 2 }} 11 | {{ p2pFlags | wrappedFlagUsages | trimTrailingWhitespaces | indent 4 }} 12 | 13 | Usage: 14 | {{ .UseLine }} 15 | ` 16 | -------------------------------------------------------------------------------- /v2/pkg/commands/p2p/client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "github.com/forestnode-io/oneshot/v2/pkg/commands/p2p/client/receive" 5 | "github.com/forestnode-io/oneshot/v2/pkg/commands/p2p/client/send" 6 | "github.com/forestnode-io/oneshot/v2/pkg/configuration" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | func New(config *configuration.Root) *Cmd { 11 | return &Cmd{ 12 | config: config, 13 | } 14 | } 15 | 16 | type Cmd struct { 17 | cobraCommand *cobra.Command 18 | config *configuration.Root 19 | } 20 | 21 | func (c *Cmd) Cobra() *cobra.Command { 22 | if c.cobraCommand != nil { 23 | return c.cobraCommand 24 | } 25 | 26 | c.cobraCommand = &cobra.Command{ 27 | Use: "client", 28 | Short: "WebRTC client commands", 29 | Long: "WebRTC client commands", 30 | } 31 | 32 | c.cobraCommand.SetUsageTemplate(usageTemplate) 33 | 34 | c.cobraCommand.AddCommand(subCommands(c.config)...) 35 | 36 | return c.cobraCommand 37 | } 38 | 39 | func subCommands(config *configuration.Root) []*cobra.Command { 40 | return []*cobra.Command{ 41 | send.New(config).Cobra(), 42 | receive.New(config).Cobra(), 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /v2/pkg/commands/p2p/client/configuration/configuration.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | receive "github.com/forestnode-io/oneshot/v2/pkg/commands/p2p/client/receive/configuration" 5 | send "github.com/forestnode-io/oneshot/v2/pkg/commands/p2p/client/send/configuration" 6 | ) 7 | 8 | type Configuration struct { 9 | Receive *receive.Configuration `mapstructure:"receive" yaml:"receive"` 10 | Send *send.Configuration `mapstructure:"send" yaml:"send"` 11 | } 12 | -------------------------------------------------------------------------------- /v2/pkg/commands/p2p/client/discovery/discoveryServerNegotiation.go: -------------------------------------------------------------------------------- 1 | package discovery 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | 9 | discoveryserver "github.com/forestnode-io/oneshot/v2/pkg/commands/discovery-server" 10 | "github.com/rs/zerolog" 11 | ) 12 | 13 | func NegotiateOfferRequest(ctx context.Context, url, username, password string, client *http.Client) (*discoveryserver.ClientOfferRequestResponse, error) { 14 | log := zerolog.Ctx(ctx) 15 | 16 | // perform the first request which saves our spot in the queue. 17 | // we're going to use the same pathways as browser clients to we 18 | // set the accept header to text/http and the user agent to oneshot. 19 | // the server will respond differently based on the user agent, it won't send html. 20 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) 21 | if err != nil { 22 | return nil, fmt.Errorf("failed to create token request: %w", err) 23 | } 24 | req.Header.Set("Accept", "text/html") 25 | req.Header.Set("User-Agent", "oneshot") 26 | if username != "" || password != "" { 27 | req.SetBasicAuth(username, password) 28 | } 29 | 30 | resp, err := client.Do(req) 31 | if err != nil { 32 | return nil, fmt.Errorf("failed to get token request response: %w", err) 33 | } 34 | defer resp.Body.Close() 35 | if resp.StatusCode != http.StatusOK { 36 | return nil, fmt.Errorf("%s", resp.Status) 37 | } 38 | 39 | log.Debug(). 40 | Int("status", resp.StatusCode). 41 | Interface("headers", resp.Header). 42 | Msg("got token request response") 43 | 44 | cookies := resp.Cookies() 45 | sessionToken := "" 46 | for _, cookie := range cookies { 47 | if cookie.Name == "session_token" { 48 | sessionToken = cookie.Value 49 | break 50 | } 51 | } 52 | if sessionToken == "" { 53 | return nil, fmt.Errorf("failed to get session token") 54 | } 55 | 56 | req, err = http.NewRequest(http.MethodGet, url, nil) 57 | if err != nil { 58 | return nil, fmt.Errorf("failed to create offer request: %w", err) 59 | } 60 | req.Header.Set("Accept", "application/json") 61 | req.Header.Set("X-Session-Token", sessionToken) 62 | resp, err = http.DefaultClient.Do(req) 63 | if err != nil { 64 | return nil, fmt.Errorf("failed to request offer response: %w", err) 65 | } 66 | defer resp.Body.Close() 67 | 68 | var corr discoveryserver.ClientOfferRequestResponse 69 | if err := json.NewDecoder(resp.Body).Decode(&corr); err != nil { 70 | return nil, fmt.Errorf("failed to decode offer request response: %w", err) 71 | } 72 | 73 | log.Debug(). 74 | Int("status", resp.StatusCode). 75 | Interface("headers", resp.Header). 76 | Interface("response_object", corr). 77 | Msg("got offer request response") 78 | 79 | return &corr, nil 80 | } 81 | -------------------------------------------------------------------------------- /v2/pkg/commands/p2p/client/receive/configuration/configuration.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | "github.com/spf13/pflag" 6 | ) 7 | 8 | type Configuration struct{} 9 | 10 | func (c *Configuration) Validate() error { 11 | return nil 12 | } 13 | 14 | func SetFlags(cmd *cobra.Command) { 15 | fs := pflag.NewFlagSet("redirect flags", pflag.ContinueOnError) 16 | defer cmd.Flags().AddFlagSet(fs) 17 | 18 | cobra.AddTemplateFunc("redirectFlags", func() *pflag.FlagSet { 19 | return fs 20 | }) 21 | } 22 | -------------------------------------------------------------------------------- /v2/pkg/commands/p2p/client/receive/usage.go: -------------------------------------------------------------------------------- 1 | package receive 2 | 3 | const usageTemplate = `Receive options: 4 | {{ .LocalFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 5 | 6 | Discovery options: 7 | {{ discoveryFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 8 | 9 | Output options: 10 | {{ outputClientFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 11 | 12 | Basic Authentication options: 13 | {{ basicAuthClientFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 14 | 15 | NAT Traversal options: 16 | {{ "P2P options:" | indent 2 }} 17 | {{ p2pClientFlags | wrappedFlagUsages | trimTrailingWhitespaces | indent 4 }} 18 | ` 19 | -------------------------------------------------------------------------------- /v2/pkg/commands/p2p/client/send/configuration/configuration.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "github.com/forestnode-io/oneshot/v2/pkg/flags" 5 | "github.com/spf13/cobra" 6 | "github.com/spf13/pflag" 7 | ) 8 | 9 | type Configuration struct { 10 | Name string `json:"name" yaml:"name"` 11 | ArchiveMethod string `json:"archivemethod" yaml:"archivemethod"` 12 | } 13 | 14 | func (c *Configuration) Validate() error { 15 | return nil 16 | } 17 | 18 | func SetFlags(cmd *cobra.Command) { 19 | fs := pflag.NewFlagSet("send flags", pflag.ExitOnError) 20 | defer cmd.Flags().AddFlagSet(fs) 21 | 22 | flags.StringP(fs, "cmd.p2p.client.send.name", "name", "n", "Name of file presented to the server.") 23 | flags.StringP(fs, "cmd.p2p.client.send.archivemethod", "archive-method", "a", `Which archive method to use when sending directories. 24 | Recognized values are "zip", "tar" and "tar.gz".`) 25 | 26 | cobra.AddTemplateFunc("sendFlags", func() *pflag.FlagSet { 27 | return fs 28 | }) 29 | } 30 | -------------------------------------------------------------------------------- /v2/pkg/commands/p2p/client/send/usage.go: -------------------------------------------------------------------------------- 1 | package send 2 | 3 | const usageTemplate = `Send options: 4 | {{ .LocalFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 5 | 6 | Discovery options: 7 | {{ discoveryFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 8 | 9 | Output options: 10 | {{ outputClientFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 11 | 12 | Basic Authentication options: 13 | {{ basicAuthClientFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 14 | 15 | NAT Traversal options: 16 | {{ "P2P options:" | indent 2 }} 17 | {{ p2pClientFlags | wrappedFlagUsages | trimTrailingWhitespaces | indent 4 }} 18 | ` 19 | -------------------------------------------------------------------------------- /v2/pkg/commands/p2p/client/usage.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | const usageTemplate = `Usage: 4 | {{ .CommandPath }} [command] 5 | 6 | Available Commands: {{ range .Commands }}{{if (or .IsAvailableCommand (eq .Name "help"))}} 7 | {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}} 8 | ` 9 | -------------------------------------------------------------------------------- /v2/pkg/commands/p2p/configuration/configuration.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | browserclient "github.com/forestnode-io/oneshot/v2/pkg/commands/p2p/browser-client/configuration" 5 | client "github.com/forestnode-io/oneshot/v2/pkg/commands/p2p/client/configuration" 6 | ) 7 | 8 | type Configuration struct { 9 | BrowserClient *browserclient.Configuration `mapstructure:"browserclient" yaml:"browserclient"` 10 | Client *client.Configuration `mapstructure:"client" yaml:"client"` 11 | } 12 | -------------------------------------------------------------------------------- /v2/pkg/commands/p2p/p2p.go: -------------------------------------------------------------------------------- 1 | package p2p 2 | 3 | import ( 4 | browserclient "github.com/forestnode-io/oneshot/v2/pkg/commands/p2p/browser-client" 5 | "github.com/forestnode-io/oneshot/v2/pkg/commands/p2p/client" 6 | "github.com/forestnode-io/oneshot/v2/pkg/configuration" 7 | "github.com/spf13/cobra" 8 | ) 9 | 10 | func New(config *configuration.Root) *Cmd { 11 | return &Cmd{ 12 | config: config, 13 | } 14 | } 15 | 16 | type Cmd struct { 17 | cobraCommand *cobra.Command 18 | config *configuration.Root 19 | } 20 | 21 | func (c *Cmd) Cobra() *cobra.Command { 22 | if c.cobraCommand != nil { 23 | return c.cobraCommand 24 | } 25 | 26 | c.cobraCommand = &cobra.Command{ 27 | Use: "p2p", 28 | Aliases: []string{"webrtc"}, 29 | Short: "Peer-to-peer commands", 30 | Long: "Peer-to-peer commands", 31 | } 32 | 33 | c.cobraCommand.SetUsageTemplate(usageTemplate) 34 | 35 | c.cobraCommand.AddCommand(subCommands(c.config)...) 36 | 37 | return c.cobraCommand 38 | } 39 | 40 | func subCommands(config *configuration.Root) []*cobra.Command { 41 | return []*cobra.Command{ 42 | client.New(config).Cobra(), 43 | browserclient.New(config).Cobra(), 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /v2/pkg/commands/p2p/usage.go: -------------------------------------------------------------------------------- 1 | package p2p 2 | 3 | const usageTemplate = `Usage: 4 | {{ .CommandPath }} [command] 5 | 6 | Available Commands: {{ range .Commands }}{{if (or .IsAvailableCommand (eq .Name "help"))}} 7 | {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}} 8 | ` 9 | -------------------------------------------------------------------------------- /v2/pkg/commands/receive/configuration/configuration.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os" 7 | 8 | "github.com/forestnode-io/oneshot/v2/pkg/flags" 9 | "github.com/spf13/cobra" 10 | "github.com/spf13/pflag" 11 | ) 12 | 13 | type Configuration struct { 14 | CSRFToken string `mapstructure:"csrftoken" yaml:"csrftoken"` 15 | EOL string `mapstructure:"eol" yaml:"eol"` 16 | UI string `mapstructure:"uifile" yaml:"uifile"` 17 | DecodeBase64 bool `mapstructure:"decodeb64" yaml:"decodeb64"` 18 | StatusCode int `mapstructure:"status" yaml:"status"` 19 | IncludeBody bool `mapstructure:"includebody" yaml:"includebody"` 20 | StreamToStdout bool `mapstructure:"stream" yaml:"stream"` 21 | } 22 | 23 | func (c *Configuration) Validate() error { 24 | if (c.EOL != "unix" && c.EOL != "dos") && c.EOL != "" { 25 | return fmt.Errorf("invalid eol: %s", c.EOL) 26 | } 27 | 28 | if c.UI != "" { 29 | stat, err := os.Stat(c.UI) 30 | if err != nil { 31 | return fmt.Errorf("invalid ui file: %w", err) 32 | } 33 | if stat.IsDir() { 34 | return fmt.Errorf("invalid ui file: %s is a directory", c.UI) 35 | } 36 | } 37 | 38 | if t := http.StatusText(c.StatusCode); t == "" { 39 | return fmt.Errorf("invalid status code: %d", c.StatusCode) 40 | } 41 | 42 | return nil 43 | } 44 | 45 | func (c *Configuration) Hydrate() error { 46 | return nil 47 | } 48 | 49 | func SetFlags(cmd *cobra.Command) { 50 | fs := pflag.NewFlagSet("receive flags", pflag.ExitOnError) 51 | defer cmd.Flags().AddFlagSet(fs) 52 | 53 | flags.String(fs, "cmd.receive.csrftoken", "csrf-token", "Use a CSRF token, if left empty, a random token will be generated.") 54 | flags.String(fs, "cmd.receive.eol", "eol", `How to parse EOLs in the received file. 55 | Acceptable values are 'unix' and 'dos'; 'unix': '\n', 'dos': '\r\n'.`) 56 | flags.StringP(fs, "cmd.receive.uifile", "ui", "U", "Name of ui file to use.") 57 | flags.Bool(fs, "cmd.receive.decodeb64", "decode-b64", "Decode base-64.") 58 | flags.Int(fs, "cmd.receive.status", "status-code", "HTTP status code sent to client.") 59 | flags.Bool(fs, "cmd.receive.includebody", "include-body", "Include the request body in the report. If not using json output, this will be ignored.") 60 | flags.Bool(fs, "cmd.receive.stream", "stream", `Stream request body without buffering. 61 | Contents of failed transfer attempts will be included. 62 | Only applied when receiving to stdout.`) 63 | 64 | cobra.AddTemplateFunc("receiveFlags", func() *pflag.FlagSet { 65 | return fs 66 | }) 67 | } 68 | -------------------------------------------------------------------------------- /v2/pkg/commands/receive/index.template.html: -------------------------------------------------------------------------------- 1 | {{ define "file-section" }}
2 | {{ if ne .CSRFToken "" }}{{ end }} 3 |
Select a file to upload
4 | 5 |

6 | 7 |
8 | {{ end }} 9 | 10 | {{ define "text-section" }}
11 | {{ if ne .CSRFToken "" }}{{ end }} 12 |
Or paste the contents of a file here:
13 | 14 |

15 | 16 |
17 | {{ end }} 18 | 19 | {{ define "index" }} 20 | 21 | {{ if .IconURL }} 22 | 23 | 24 | {{ end }} 25 | {{ if .FileSection }}{{ template "file-section" . }} 26 | {{ end }}{{ if .InputSection }}{{ if .FileSection }}
OR
27 | {{ end }}{{ template "text-section" . }} 28 | {{ end }} 29 | {{ .ClientJS }} 30 | 31 | {{ end }} 32 | 33 | {{ template "index" .}} -------------------------------------------------------------------------------- /v2/pkg/commands/receive/main.js: -------------------------------------------------------------------------------- 1 | "use strict";(()=>{function o(e){const t=[];var n=0;for(const o of e.entries()){n++;const r=o[1];if(r instanceof File){const s=r.name;const c=r.size;t.push(s+"="+c.toString())}}if(n===0){return Promise.reject(new Error("cannot send empty data"))}return fetch("/",{method:"POST",headers:{"X-Oneshot-Multipart-Content-Lengths":t.join(";")},body:e})}function n(e){if(e.length===0){return Promise.reject(new Error("cannot send empty data"))}return fetch("/",{method:"POST",headers:{"Content-Length":e.length.toString()},body:e})}function e(){console.log("running main");t();r()}function t(){const n=document.getElementById("file-form");if(!n){return}n.addEventListener("submit",e=>{e.preventDefault();e.stopPropagation();const t=new FormData(n);s(o(t))})}function r(){const t=document.getElementById("text-input");const e=document.getElementById("text-form");if(!t||!e){return}e.addEventListener("submit",e=>{e.preventDefault();e.stopPropagation();s(n(t.value))})}function s(e){e.then(e=>{if(e.ok){console.log("Transfer succeeded");document.body.innerHTML="Transfer succeeded"}else{const t="Transfer failed: "+e.status.toString+" "+e.statusText;console.log(t);document.body.innerHTML=t}}).catch(e=>{if(e instanceof Error){if(e.message==="cannot send empty data"){console.log(e.message);alert(e.message);return}}const t="Transfer failed: "+e;console.log(t);document.body.innerHTML=t})}e()})(); -------------------------------------------------------------------------------- /v2/pkg/commands/receive/usage.go: -------------------------------------------------------------------------------- 1 | package receive 2 | 3 | const usageTemplate = `Receive options: 4 | {{ .LocalFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 5 | 6 | Discovery options: 7 | {{ discoveryFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 8 | 9 | Output options: 10 | {{ outputFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 11 | 12 | Server options: 13 | {{ serverFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 14 | 15 | Basic Authentication options: 16 | {{ basicAuthFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 17 | 18 | CORS options: 19 | {{ corsFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 20 | 21 | NAT Traversal options: 22 | {{ "P2P options:" | indent 2 }} 23 | {{ p2pFlags | wrappedFlagUsages | trimTrailingWhitespaces | indent 4 }} 24 | {{ "Port mapping options:" | indent 2 }} 25 | {{ upnpFlags | wrappedFlagUsages | trimTrailingWhitespaces | indent 4 }} 26 | 27 | Usage: 28 | {{.UseLine}} 29 | ` 30 | -------------------------------------------------------------------------------- /v2/pkg/commands/receive/util.go: -------------------------------------------------------------------------------- 1 | package receive 2 | 3 | import ( 4 | "regexp" 5 | "strings" 6 | ) 7 | 8 | var ( 9 | lf = []byte{10} 10 | crlf = []byte{13, 10} 11 | ) 12 | 13 | var regex = regexp.MustCompile(`^?\w?filename="?(.+)"?\w?$?`) 14 | 15 | func fileName(s string) string { 16 | subs := regex.FindStringSubmatch(s) 17 | if len(subs) > 1 { 18 | ss := strings.TrimSuffix(subs[1], `"`) 19 | return strings.TrimSuffix(ss, `;`) 20 | } 21 | return "" 22 | } 23 | -------------------------------------------------------------------------------- /v2/pkg/commands/receive/webrtc-client.js: -------------------------------------------------------------------------------- 1 | "use strict";(()=>{function o(e){const t=[];var n=0;for(const o of e.entries()){n++;const r=o[1];if(r instanceof File){const s=r.name;const c=r.size;t.push(s+"="+c.toString())}}if(n===0){return Promise.reject(new Error("cannot send empty data"))}return fetch("/",{method:"POST",headers:{"X-Oneshot-Multipart-Content-Lengths":t.join(";")},body:e})}function n(e){if(e.length===0){return Promise.reject(new Error("cannot send empty data"))}return fetch("/",{method:"POST",headers:{"Content-Length":e.length.toString()},body:e})}function e(){console.log("running main");t();r()}function t(){const n=document.getElementById("file-form");if(!n){return}n.addEventListener("submit",e=>{e.preventDefault();e.stopPropagation();const t=new FormData(n);s(o(t))})}function r(){const t=document.getElementById("text-input");const e=document.getElementById("text-form");if(!t||!e){return}e.addEventListener("submit",e=>{e.preventDefault();e.stopPropagation();s(n(t.value))})}function s(e){e.then(e=>{if(e.ok){console.log("Transfer succeeded");document.body.innerHTML="Transfer succeeded"}else{const t="Transfer failed: "+e.status.toString+" "+e.statusText;console.log(t);document.body.innerHTML=t}}).catch(e=>{if(e instanceof Error){if(e.message==="cannot send empty data"){console.log(e.message);alert(e.message);return}}const t="Transfer failed: "+e;console.log(t);document.body.innerHTML=t})}e()})(); -------------------------------------------------------------------------------- /v2/pkg/commands/redirect/cobra.go: -------------------------------------------------------------------------------- 1 | package redirect 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | 7 | "github.com/forestnode-io/oneshot/v2/pkg/commands" 8 | "github.com/forestnode-io/oneshot/v2/pkg/commands/redirect/configuration" 9 | rootconfig "github.com/forestnode-io/oneshot/v2/pkg/configuration" 10 | "github.com/forestnode-io/oneshot/v2/pkg/events" 11 | "github.com/forestnode-io/oneshot/v2/pkg/output" 12 | "github.com/spf13/cobra" 13 | ) 14 | 15 | func New(config *rootconfig.Root) *Cmd { 16 | return &Cmd{ 17 | config: config, 18 | } 19 | } 20 | 21 | type Cmd struct { 22 | cobraCommand *cobra.Command 23 | config *rootconfig.Root 24 | url string 25 | } 26 | 27 | func (c *Cmd) Cobra() *cobra.Command { 28 | if c.cobraCommand != nil { 29 | return c.cobraCommand 30 | } 31 | 32 | c.cobraCommand = &cobra.Command{ 33 | Use: "redirect url", 34 | Short: "Redirect all requests to the specified url", 35 | RunE: c.setHandlerFunc, 36 | Args: func(cmd *cobra.Command, args []string) error { 37 | if len(args) < 1 { 38 | return output.UsageErrorF("redirect url required") 39 | } 40 | if 1 < len(args) { 41 | return output.UsageErrorF("too many arguments, only 1 url may be used") 42 | } 43 | return nil 44 | }, 45 | } 46 | 47 | c.cobraCommand.SetUsageTemplate(usageTemplate) 48 | configuration.SetFlags(c.cobraCommand) 49 | 50 | return c.cobraCommand 51 | } 52 | 53 | func (c *Cmd) setHandlerFunc(cmd *cobra.Command, args []string) error { 54 | var ctx = cmd.Context() 55 | 56 | output.IncludeBody(ctx) 57 | 58 | c.url = args[0] 59 | 60 | commands.SetHTTPHandlerFunc(ctx, c.ServeHTTP) 61 | return nil 62 | } 63 | 64 | func (c *Cmd) ServeHTTP(w http.ResponseWriter, r *http.Request) { 65 | var ( 66 | ctx = c.cobraCommand.Context() 67 | config = c.config.Subcommands.Redirect 68 | ) 69 | 70 | doneReadingBody := make(chan struct{}) 71 | events.Raise(ctx, output.NewHTTPRequest(r, nil)) 72 | 73 | var header = http.Header(config.Header.Inflate()) 74 | for key := range header { 75 | w.Header().Set(key, header.Get(key)) 76 | } 77 | 78 | go func() { 79 | defer close(doneReadingBody) 80 | defer r.Body.Close() 81 | _, _ = io.Copy(io.Discard, r.Body) 82 | }() 83 | 84 | http.Redirect(w, r, c.url, config.StatusCode) 85 | 86 | events.Success(ctx) 87 | <-doneReadingBody 88 | } 89 | -------------------------------------------------------------------------------- /v2/pkg/commands/redirect/configuration/configuration.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/forestnode-io/oneshot/v2/pkg/flagargs" 8 | "github.com/forestnode-io/oneshot/v2/pkg/flags" 9 | "github.com/spf13/cobra" 10 | "github.com/spf13/pflag" 11 | ) 12 | 13 | type Configuration struct { 14 | StatusCode int `mapstructure:"status" yaml:"status"` 15 | Header flagargs.HTTPHeader `mapstructure:"header" yaml:"header"` 16 | } 17 | 18 | func (c *Configuration) Validate() error { 19 | if t := http.StatusText(c.StatusCode); t == "" { 20 | return fmt.Errorf("invalid status code") 21 | } 22 | return nil 23 | } 24 | 25 | func (c *Configuration) Hydrate() error { 26 | return nil 27 | } 28 | 29 | func SetFlags(cmd *cobra.Command) { 30 | fs := pflag.NewFlagSet("redirect flags", pflag.ContinueOnError) 31 | defer cmd.Flags().AddFlagSet(fs) 32 | 33 | flags.Int(fs, "cmd.redirect.status", "status-code", "HTTP status code to send to client.") 34 | flags.StringSliceP(fs, "cmd.redirect.header", "header", "H", `Header to send to client. Can be specified multiple times. 35 | Format:
=
`) 36 | 37 | cobra.AddTemplateFunc("redirectFlags", func() *pflag.FlagSet { 38 | return fs 39 | }) 40 | } 41 | -------------------------------------------------------------------------------- /v2/pkg/commands/redirect/usage.go: -------------------------------------------------------------------------------- 1 | package redirect 2 | 3 | const usageTemplate = `Redirect options: 4 | {{ .LocalFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 5 | 6 | Discovery options: 7 | {{ discoveryFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 8 | 9 | Output options: 10 | {{ outputFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 11 | 12 | Server options: 13 | {{ serverFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 14 | 15 | Basic Authentication options: 16 | {{ basicAuthFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 17 | 18 | CORS options: 19 | {{ corsFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 20 | 21 | NAT Traversal options: 22 | {{ "P2P options:" | indent 2 }} 23 | {{ p2pFlags | wrappedFlagUsages | trimTrailingWhitespaces | indent 4 }} 24 | {{ "Port mapping options:" | indent 2 }} 25 | {{ upnpFlags | wrappedFlagUsages | trimTrailingWhitespaces | indent 4 }} 26 | 27 | Usage: 28 | {{.UseLine}} 29 | ` 30 | -------------------------------------------------------------------------------- /v2/pkg/commands/root/configureServer.go: -------------------------------------------------------------------------------- 1 | package root 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "os" 7 | 8 | "github.com/forestnode-io/oneshot/v2/pkg/configuration" 9 | oneshothttp "github.com/forestnode-io/oneshot/v2/pkg/net/http" 10 | "github.com/rs/cors" 11 | ) 12 | 13 | func (r *rootCommand) configureServer() (string, error) { 14 | var ( 15 | sConf = r.config.Server 16 | timeout = sConf.Timeout 17 | allowBots = sConf.AllowBots 18 | exitOnFail = sConf.ExitOnFail 19 | 20 | baConf = r.config.BasicAuth 21 | uname = baConf.Username 22 | passwd = baConf.Password 23 | 24 | err error 25 | ) 26 | 27 | var ( 28 | unauthenticatedViewBytes []byte 29 | unauthenticatedStatus int 30 | ) 31 | if uname != "" || (uname != "" && passwd != "") { 32 | viewPath := baConf.UnauthorizedPage 33 | if viewPath != "" { 34 | unauthenticatedViewBytes, err = os.ReadFile(viewPath) 35 | if err != nil { 36 | return "", fmt.Errorf("failed to read unauthorized page: %w", err) 37 | } 38 | } 39 | 40 | unauthenticatedStatus = baConf.UnauthorizedStatus 41 | } 42 | 43 | goneHandler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 44 | w.WriteHeader(http.StatusGone) 45 | }) 46 | 47 | var corsMW func(http.Handler) http.Handler 48 | if copts := corsOptionsFromConfig(&r.config.CORS); copts != nil { 49 | corsMW = cors.New(*copts).Handler 50 | } 51 | 52 | noLoginTrigger := baConf.NoDialog 53 | baMiddleware, baToken, err := oneshothttp.BasicAuthMiddleware( 54 | unauthenticatedHandler(!noLoginTrigger, unauthenticatedStatus, unauthenticatedViewBytes), 55 | uname, passwd) 56 | if err != nil { 57 | return "", fmt.Errorf("failed to create basic auth middleware: %w", err) 58 | } 59 | 60 | maxReadSize, err := configuration.ParseSizeString(sConf.MaxReadSize) 61 | if err != nil { 62 | return "", fmt.Errorf("failed to parse max read size: %w", err) 63 | } 64 | 65 | r.server = oneshothttp.NewServer(r.Context(), r.handler, goneHandler, []oneshothttp.Middleware{ 66 | r.middleware. 67 | Chain(oneshothttp.BlockPrefetch("Safari")). 68 | Chain(oneshothttp.LimitReaderMiddleware(maxReadSize)). 69 | Chain(oneshothttp.MiddlewareShim(corsMW)). 70 | Chain(oneshothttp.BotsMiddleware(allowBots)). 71 | Chain(baMiddleware), 72 | }...) 73 | r.server.TLSCert = sConf.TLSCert 74 | r.server.TLSKey = sConf.TLSKey 75 | r.server.Timeout = timeout 76 | r.server.ExitOnFail = exitOnFail 77 | 78 | return baToken, nil 79 | } 80 | -------------------------------------------------------------------------------- /v2/pkg/commands/root/discoveryServer.go: -------------------------------------------------------------------------------- 1 | package root 2 | 3 | import ( 4 | "context" 5 | "crypto/sha256" 6 | "fmt" 7 | "os" 8 | 9 | oneshotnet "github.com/forestnode-io/oneshot/v2/pkg/net" 10 | "github.com/forestnode-io/oneshot/v2/pkg/net/webrtc/signallingserver" 11 | "github.com/forestnode-io/oneshot/v2/pkg/net/webrtc/signallingserver/messages" 12 | "golang.org/x/crypto/bcrypt" 13 | ) 14 | 15 | func (r *rootCommand) sendArrivalToDiscoveryServer(ctx context.Context, cmd string) error { 16 | var ( 17 | config = r.config 18 | dsConfig = config.Discovery 19 | ) 20 | 21 | if dsConfig.Addr == "" { 22 | return nil 23 | } 24 | 25 | arrival := messages.ServerArrivalRequest{ 26 | IsUsingPortMapping: config.NATTraversal.IsUsingUPnP(), 27 | RedirectOnly: !config.NATTraversal.P2P.Enabled, 28 | TTL: config.Server.Timeout, 29 | Cmd: cmd, 30 | } 31 | 32 | ipThatCanReachDiscoveryServer, err := oneshotnet.GetSourceIP(dsConfig.Addr, 0) 33 | if err != nil { 34 | return fmt.Errorf("unable to reach the discovery server: %w", err) 35 | } 36 | arrival.Redirect = ipThatCanReachDiscoveryServer.String() 37 | if ipThatCanReachDiscoveryServer.To16() != nil { 38 | arrival.Redirect = "[" + arrival.Redirect + "]" 39 | } 40 | 41 | scheme := "http" 42 | if config.Server.TLSCert != "" { 43 | scheme = "https" 44 | } 45 | arrival.Redirect = fmt.Sprintf("%s://%s:%d", scheme, arrival.Redirect, config.Server.Port) 46 | 47 | if arrival.IsUsingPortMapping { 48 | if d := config.NATTraversal.UPnP.Duration; d != 0 && d < arrival.TTL { 49 | arrival.TTL = d 50 | } 51 | } 52 | 53 | if dsConfig.PreferredURL != "" || dsConfig.RequiredURL != "" { 54 | switch { 55 | case dsConfig.RequiredURL != "": 56 | arrival.URL = &messages.SessionURLRequest{ 57 | URL: dsConfig.RequiredURL, 58 | Required: true, 59 | } 60 | case dsConfig.PreferredURL != "": 61 | arrival.URL = &messages.SessionURLRequest{ 62 | URL: dsConfig.PreferredURL, 63 | Required: false, 64 | } 65 | } 66 | } 67 | 68 | if !arrival.RedirectOnly { 69 | var ( 70 | baConf = config.BasicAuth 71 | username = baConf.Username 72 | password = baConf.Password 73 | bam *messages.BasicAuth 74 | ) 75 | 76 | if username != "" || password != "" { 77 | bam = &messages.BasicAuth{} 78 | if username != "" { 79 | uHash := sha256.Sum256([]byte(username)) 80 | bam.UsernameHash = uHash[:] 81 | } 82 | if password != "" { 83 | pHash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost) 84 | if err != nil { 85 | return fmt.Errorf("failed to hash password: %w", err) 86 | } 87 | bam.PasswordHash = pHash 88 | } 89 | } 90 | 91 | arrival.BasicAuth = bam 92 | } 93 | 94 | hostname, _ := os.Hostname() 95 | arrival.Hostname = hostname 96 | 97 | return signallingserver.SendArrivalToDiscoveryServer(ctx, &arrival) 98 | } 99 | -------------------------------------------------------------------------------- /v2/pkg/commands/root/help.go: -------------------------------------------------------------------------------- 1 | package root 2 | 3 | const helpTemplate = `{{with (or .Long .Short)}}{{. | trimTrailingWhitespaces}} 4 | 5 | {{end}}{{if or .Runnable .HasSubCommands}}{{.UsageString}}{{end}} 6 | If you encounter any bugs or have any questions or suggestions, please open an issue at: 7 | https://github.com/forestnode-io/oneshot/issues/new/choose 8 | ` 9 | -------------------------------------------------------------------------------- /v2/pkg/commands/root/usage.go: -------------------------------------------------------------------------------- 1 | package root 2 | 3 | const usageTemplate = `Usage: 4 | {{.CommandPath}} [command] 5 | 6 | Available Commands:{{range .Commands}}{{if (or .IsAvailableCommand (eq .Name "help"))}} 7 | {{rpad .Name .NamePadding }} {{.Short}}{{end}}{{end}} 8 | ` 9 | -------------------------------------------------------------------------------- /v2/pkg/commands/rproxy/configuration/configuration.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/forestnode-io/oneshot/v2/pkg/flagargs" 8 | "github.com/forestnode-io/oneshot/v2/pkg/flags" 9 | "github.com/spf13/cobra" 10 | "github.com/spf13/pflag" 11 | ) 12 | 13 | type Configuration struct { 14 | StatusCode int `mapstructure:"status" yaml:"status"` 15 | Method string `mapstructure:"method" yaml:"method"` 16 | MatchHost bool `mapstructure:"matchhost" yaml:"matchhost"` 17 | Tee bool `mapstructure:"tee" yaml:"tee"` 18 | SpoofHost string `mapstructure:"spoofhost" yaml:"spoofhost"` 19 | RequestHeader flagargs.HTTPHeader `mapstructure:"requestheader" yaml:"requestheader"` 20 | ResponseHeader flagargs.HTTPHeader `mapstructure:"responseheader" yaml:"responseheader"` 21 | } 22 | 23 | func (c *Configuration) Validate() error { 24 | if t := http.StatusText(c.StatusCode); t == "" && c.StatusCode != 0 { 25 | return fmt.Errorf("invalid status code") 26 | } 27 | 28 | return nil 29 | } 30 | 31 | func (c *Configuration) Hydrate() error { 32 | return nil 33 | } 34 | 35 | func SetFlags(cmd *cobra.Command) { 36 | fs := pflag.NewFlagSet("send flags", pflag.ContinueOnError) 37 | defer cmd.Flags().AddFlagSet(fs) 38 | 39 | flags.Int(fs, "cmd.rproxy.status", "status-code", "HTTP status code to send to client.") 40 | flags.String(fs, "cmd.rproxy.method", "method", "HTTP method to send to client.") 41 | flags.Bool(fs, "cmd.rproxy.matchhost", "match-host", `The 'Host' header will be set to match the host being reverse-proxied to.`) 42 | flags.Bool(fs, "cmd.rproxy.tee", "tee", `Send a copy of the proxied response to the console.`) 43 | flags.String(fs, "cmd.rproxy.spoofhost", "spoof-host", `Spoof the request host, the 'Host' header will be set to this value. 44 | This Flag is ignored if the --match-host flag is set.`) 45 | flags.StringSlice(fs, "cmd.rproxy.requestheader", "request-header", `Header to send with the proxied request. Can be specified multiple times. 46 | Format:
=
`) 47 | flags.StringSlice(fs, "cmd.rproxy.responseheader", "response-header", `Header to send to send with the proxied response. Can be specified multiple times. 48 | Format:
=
`) 49 | 50 | cobra.AddTemplateFunc("sendFlags", func() *pflag.FlagSet { 51 | return fs 52 | }) 53 | } 54 | -------------------------------------------------------------------------------- /v2/pkg/commands/rproxy/usage.go: -------------------------------------------------------------------------------- 1 | package rproxy 2 | 3 | const usageTemplate = `Reverse proxy options: 4 | {{ .LocalFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 5 | 6 | Discovery options: 7 | {{ discoveryFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 8 | 9 | Output options: 10 | {{ outputFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 11 | 12 | Server options: 13 | {{ serverFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 14 | 15 | Basic Authentication options: 16 | {{ basicAuthFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 17 | 18 | CORS options: 19 | {{ corsFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 20 | 21 | NAT Traversal options: 22 | {{ "P2P options:" | indent 2 }} 23 | {{ p2pFlags | wrappedFlagUsages | trimTrailingWhitespaces | indent 4 }} 24 | {{ "Port mapping options:" | indent 2 }} 25 | {{ upnpFlags | wrappedFlagUsages | trimTrailingWhitespaces | indent 4 }} 26 | 27 | Usage: 28 | {{.UseLine}} 29 | 30 | Aliases: 31 | {{.NameAndAliases}} 32 | ` 33 | -------------------------------------------------------------------------------- /v2/pkg/commands/send/cobra.go: -------------------------------------------------------------------------------- 1 | package send 2 | 3 | import ( 4 | "fmt" 5 | "mime" 6 | "path/filepath" 7 | 8 | "github.com/forestnode-io/oneshot/v2/pkg/commands" 9 | "github.com/forestnode-io/oneshot/v2/pkg/commands/send/configuration" 10 | rootconfig "github.com/forestnode-io/oneshot/v2/pkg/configuration" 11 | "github.com/forestnode-io/oneshot/v2/pkg/file" 12 | "github.com/forestnode-io/oneshot/v2/pkg/output" 13 | "github.com/moby/moby/pkg/namesgenerator" 14 | "github.com/spf13/cobra" 15 | ) 16 | 17 | func New(config *rootconfig.Root) *Cmd { 18 | return &Cmd{ 19 | config: config, 20 | } 21 | } 22 | 23 | type Cmd struct { 24 | rtc file.ReadTransferConfig 25 | cobraCommand *cobra.Command 26 | 27 | config *rootconfig.Root 28 | } 29 | 30 | func (c *Cmd) Cobra() *cobra.Command { 31 | if c.cobraCommand != nil { 32 | return c.cobraCommand 33 | } 34 | 35 | c.cobraCommand = &cobra.Command{ 36 | Use: "send [file|dir]", 37 | Short: "Send a file or directory to the client", 38 | Long: `Send a file or directory to the client. If no file or directory is given, stdin will be used. 39 | When sending from stdin, requests are blocked until an EOF is received; content from stdin is buffered for subsequent requests. 40 | If a directory is given, it will be archived and sent to the client; oneshot does not support sending unarchived directories. 41 | `, 42 | RunE: c.setHandlerFunc, 43 | } 44 | 45 | c.cobraCommand.SetUsageTemplate(usageTemplate) 46 | configuration.SetFlags(c.cobraCommand) 47 | 48 | return c.cobraCommand 49 | } 50 | 51 | func (c *Cmd) setHandlerFunc(cmd *cobra.Command, args []string) error { 52 | var ( 53 | ctx = cmd.Context() 54 | paths = args 55 | 56 | config = c.config.Subcommands.Send 57 | fileName = config.Name 58 | fileMime = config.MIME 59 | archiveMethod = string(config.ArchiveMethod) 60 | ) 61 | 62 | output.IncludeBody(ctx) 63 | 64 | if len(paths) == 1 && fileName == "" { 65 | fileName = filepath.Base(paths[0]) 66 | } 67 | 68 | if fileName != "" && fileMime == "" { 69 | ext := filepath.Ext(fileName) 70 | fileMime = mime.TypeByExtension(ext) 71 | } 72 | 73 | if fileName == "" { 74 | fileName = namesgenerator.GetRandomName(0) 75 | } 76 | 77 | var err error 78 | c.rtc, err = file.NewReadTransferConfig(archiveMethod, args...) 79 | if err != nil { 80 | return fmt.Errorf("failed to create read transfer config: %w", err) 81 | } 82 | 83 | if file.IsArchive(c.rtc) { 84 | fileName += "." + archiveMethod 85 | } 86 | 87 | if _, ok := config.Header.GetValue("Content-Type"); !ok { 88 | config.Header.SetValue("Content-Type", fileMime) 89 | } 90 | // Are we triggering a file download on the users browser? 91 | if !config.NoDownload { 92 | if _, ok := config.Header.GetValue("Content-Disposition"); !ok { 93 | config.Header.SetValue("Content-Disposition", fmt.Sprintf("attachment;filename=%s", fileName)) 94 | } 95 | } 96 | 97 | commands.SetHTTPHandlerFunc(ctx, c.ServeHTTP) 98 | return nil 99 | } 100 | -------------------------------------------------------------------------------- /v2/pkg/commands/send/configuration/configuration.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/forestnode-io/oneshot/v2/pkg/flagargs" 8 | "github.com/forestnode-io/oneshot/v2/pkg/flags" 9 | "github.com/spf13/cobra" 10 | "github.com/spf13/pflag" 11 | ) 12 | 13 | type Configuration struct { 14 | ArchiveMethod string `mapstructure:"archivemethod" yaml:"archivemethod"` 15 | NoDownload bool `mapstructure:"nodownload" yaml:"nodownload"` 16 | MIME string `mapstructure:"mime" yaml:"mime"` 17 | Name string `mapstructure:"name" yaml:"name"` 18 | StatusCode int `mapstructure:"statuscode" yaml:"statuscode"` 19 | Header flagargs.HTTPHeader `mapstructure:"header" yaml:"header"` 20 | } 21 | 22 | func SetFlags(cmd *cobra.Command) { 23 | fs := pflag.NewFlagSet("send flags", pflag.ExitOnError) 24 | defer cmd.Flags().AddFlagSet(fs) 25 | 26 | flags.StringP(fs, "cmd.send.archivemethod", "archive-method", "a", `Which archive method to use when sending directories.`) 27 | flags.BoolP(fs, "cmd.send.nodownload", "no-download", "D", "Do not allow the client to download the file.") 28 | flags.StringP(fs, "cmd.send.mime", "mime", "m", `MIME type of file presented to client.`) 29 | flags.StringP(fs, "cmd.send.name", "name", "n", `Name of file presented to client if downloading.`) 30 | flags.Int(fs, "cmd.send.statuscode", "status-code", "HTTP status code to send to client.") 31 | flags.StringSliceP(fs, "cmd.send.header", "header", "H", `Header to send to client. Can be specified multiple times. 32 | Format:
=
`) 33 | 34 | cobra.AddTemplateFunc("sendFlags", func() *pflag.FlagSet { 35 | return fs 36 | }) 37 | } 38 | 39 | func (c *Configuration) Validate() error { 40 | if t := http.StatusText(c.StatusCode); t == "" { 41 | return fmt.Errorf("invalid status code") 42 | } 43 | return nil 44 | } 45 | 46 | func (c *Configuration) Hydrate() error { 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /v2/pkg/commands/send/serve.go: -------------------------------------------------------------------------------- 1 | package send 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "net/http" 7 | "time" 8 | 9 | "github.com/forestnode-io/oneshot/v2/pkg/events" 10 | "github.com/forestnode-io/oneshot/v2/pkg/output" 11 | ) 12 | 13 | func (c *Cmd) ServeHTTP(w http.ResponseWriter, r *http.Request) { 14 | var ( 15 | ctx = c.Cobra().Context() 16 | cmd = c.cobraCommand 17 | config = c.config.Subcommands.Send 18 | header = http.Header(config.Header.Inflate()) 19 | 20 | doneReadingBody = make(chan struct{}) 21 | ) 22 | 23 | events.Raise(ctx, output.NewHTTPRequest(r, nil)) 24 | 25 | go func() { 26 | // Read body into the void since this will trigger a 27 | // a buffer on the body which can then be included in the 28 | // json report 29 | defer close(doneReadingBody) 30 | defer r.Body.Close() 31 | _, _ = io.Copy(io.Discard, r.Body) 32 | }() 33 | 34 | rts, err := c.rtc.NewReaderTransferSession(ctx) 35 | if err != nil { 36 | http.Error(w, err.Error(), http.StatusInternalServerError) 37 | events.Raise(ctx, events.ClientDisconnected{Err: err}) 38 | return 39 | } 40 | defer rts.Close() 41 | size, err := rts.Size() 42 | if err == nil { 43 | w.Header().Set("Content-Length", fmt.Sprintf("%d", size)) 44 | } 45 | 46 | for key := range header { 47 | w.Header().Set(key, header.Get(key)) 48 | } 49 | w.WriteHeader(config.StatusCode) 50 | 51 | cancelProgDisp := output.DisplayProgress( 52 | cmd.Context(), 53 | &rts.Progress, 54 | 125*time.Millisecond, 55 | r.RemoteAddr, 56 | 0, 57 | ) 58 | defer cancelProgDisp() 59 | 60 | // Start writing the file data to the client while timing how long it takes 61 | bw, getBufBytes := output.NewBufferedWriter(ctx, w) 62 | fileReport := events.File{ 63 | Size: int64(size), 64 | TransferStartTime: time.Now(), 65 | } 66 | 67 | n, err := io.Copy(bw, rts) 68 | fileReport.TransferSize = n 69 | fileReport.TransferEndTime = time.Now() 70 | if err != nil { 71 | events.Raise(ctx, &fileReport) 72 | events.Raise(ctx, events.ClientDisconnected{Err: err}) 73 | return 74 | } 75 | 76 | fileReport.Content = getBufBytes 77 | events.Raise(ctx, &fileReport) 78 | 79 | events.Success(ctx) 80 | <-doneReadingBody 81 | } 82 | -------------------------------------------------------------------------------- /v2/pkg/commands/send/usage.go: -------------------------------------------------------------------------------- 1 | package send 2 | 3 | const usageTemplate = `Send options: 4 | {{ .LocalFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 5 | 6 | Discovery options: 7 | {{ discoveryFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 8 | 9 | Output options: 10 | {{ outputFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 11 | 12 | Server options: 13 | {{ serverFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 14 | 15 | Basic Authentication options: 16 | {{ basicAuthFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 17 | 18 | CORS options: 19 | {{ corsFlags | wrappedFlagUsages | trimTrailingWhitespaces }} 20 | 21 | NAT Traversal options: 22 | {{ "P2P options:" | indent 2 }} 23 | {{ p2pFlags | wrappedFlagUsages | trimTrailingWhitespaces | indent 4 }} 24 | {{ "Port mapping options:" | indent 2 }} 25 | {{ upnpFlags | wrappedFlagUsages | trimTrailingWhitespaces | indent 4 }} 26 | 27 | Usage: 28 | {{.UseLine}} 29 | ` 30 | -------------------------------------------------------------------------------- /v2/pkg/commands/version/cobra.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "log" 7 | "os" 8 | "strings" 9 | 10 | "github.com/forestnode-io/oneshot/v2/pkg/version" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | func New() *Cmd { 15 | return &Cmd{} 16 | } 17 | 18 | type Cmd struct { 19 | cobraCommand *cobra.Command 20 | } 21 | 22 | func (c *Cmd) Cobra() *cobra.Command { 23 | if c.cobraCommand != nil { 24 | return c.cobraCommand 25 | } 26 | c.cobraCommand = &cobra.Command{ 27 | Use: "version", 28 | Short: "Print the version information", 29 | Run: func(cmd *cobra.Command, args []string) { 30 | ofa := cmd.Flags().Lookup("output").Value.String() 31 | ofaParts := strings.Split(ofa, "=") 32 | format := "" 33 | if 0 < len(ofa) { 34 | format = ofaParts[0] 35 | } 36 | if format == "json" { 37 | payload := map[string]string{} 38 | if ver := version.Version; ver != "" { 39 | payload["version"] = ver 40 | } 41 | if apiVersion := version.Version; apiVersion != "" { 42 | payload["apiVersion"] = apiVersion 43 | } 44 | if license := version.License; license != "" { 45 | payload["license"] = license 46 | } 47 | if credit := version.Credit; credit != "" { 48 | payload["credit"] = credit 49 | } 50 | 51 | enc := json.NewEncoder(os.Stdout) 52 | enc.SetIndent("", " ") 53 | 54 | if err := enc.Encode(payload); err != nil { 55 | log.Printf("error encoding json: %v", err) 56 | } 57 | } else { 58 | if ver := version.Version; ver != "" { 59 | fmt.Printf("version: %s\n", ver) 60 | } 61 | if apiVersion := version.APIVersion; apiVersion != "" { 62 | fmt.Printf("api-version: %s\n", apiVersion) 63 | } 64 | if license := version.License; license != "" { 65 | fmt.Printf("license: %s\n", license) 66 | } 67 | if credit := version.Credit; credit != "" { 68 | fmt.Printf("credit: %s\n", credit) 69 | } 70 | } 71 | }, 72 | } 73 | 74 | c.cobraCommand.SetUsageTemplate(usageTemplate) 75 | 76 | return c.cobraCommand 77 | } 78 | -------------------------------------------------------------------------------- /v2/pkg/commands/version/usage.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | const usageTemplate = `Usage: 4 | oneshot version 5 | ` 6 | -------------------------------------------------------------------------------- /v2/pkg/configuration/cors.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | 7 | "github.com/forestnode-io/oneshot/v2/pkg/flags" 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/pflag" 10 | ) 11 | 12 | type CORS struct { 13 | AllowedOrigins []string `mapstructure:"allowedOrigins" yaml:"allowedOrigins"` 14 | AllowedHeaders []string `mapstructure:"allowedHeaders" yaml:"allowedHeaders"` 15 | MaxAge int `mapstructure:"maxAge" yaml:"maxAge"` 16 | AllowCredentials bool `mapstructure:"allowCredentials" yaml:"allowCredentials"` 17 | AllowPrivateNetwork bool `mapstructure:"allowPrivateNetwork" yaml:"allowPrivateNetwork"` 18 | SuccessStatus int `mapstructure:"successStatus" yaml:"successStatus"` 19 | } 20 | 21 | func setCORSFlags(cmd *cobra.Command) { 22 | fs := pflag.NewFlagSet("CORS Flags", pflag.ExitOnError) 23 | defer cmd.PersistentFlags().AddFlagSet(fs) 24 | 25 | flags.StringSlice(fs, "cors.allowedorigins", "cors-allowed-origins", `Comma separated list of allowed origins. 26 | An allowed origin may be a domain name, or a wildcard (*). 27 | A domain name may contain a wildcard (*).`) 28 | flags.StringSlice(fs, "cors.allowedheaders", "cors-allowed-headers", `Comma separated list of allowed headers. 29 | An allowed header may be a header name, or a wildcard (*). 30 | If a wildcard (*) is used, all headers will be allowed.`) 31 | flags.Int(fs, "cors.maxage", "cors-max-age", "How long the preflight results can be cached by the client.") 32 | flags.Bool(fs, "cors.allowcredentials", "cors-allow-credentials", `Allow credentials like cookies, basic auth headers, and ssl certs for CORS requests.`) 33 | flags.Bool(fs, "cors.allowprivatenetwork", "cors-allow-private-network", `Allow private network requests from CORS requests.`) 34 | flags.Int(fs, "cors.successstatus", "cors-success-status", `HTTP status code to return for successful CORS requests.`) 35 | 36 | cobra.AddTemplateFunc("corsFlags", func() *pflag.FlagSet { 37 | return fs 38 | }) 39 | } 40 | 41 | func (c *CORS) validate() error { 42 | if t := http.StatusText(c.SuccessStatus); t == "" { 43 | return fmt.Errorf("invalid success status code") 44 | } 45 | return nil 46 | } 47 | -------------------------------------------------------------------------------- /v2/pkg/configuration/output.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | 7 | "github.com/forestnode-io/oneshot/v2/pkg/flags" 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/pflag" 10 | ) 11 | 12 | type Output struct { 13 | Quiet bool `mapstructure:"quiet" yaml:"quiet"` 14 | Format string `mapstructure:"format" yaml:"format"` 15 | QRCode bool `mapstructure:"qrCode" yaml:"qrCode"` 16 | NoColor bool `mapstructure:"noColor" yaml:"noColor"` 17 | } 18 | 19 | func setOutputFlags(cmd *cobra.Command) { 20 | fs := pflag.NewFlagSet("Output Flags", pflag.ExitOnError) 21 | defer cmd.PersistentFlags().AddFlagSet(fs) 22 | 23 | flags.BoolP(fs, "output.quiet", "quiet", "q", "Disable all output except for received data") 24 | flags.StringP(fs, "output.format", "output", "o", `Set output format. Valid formats are: json[=opts]. 25 | Valid json opts are: 26 | - compact 27 | Disables tabbed, pretty printed json. 28 | - include-file-contents 29 | Includes the contents of files in the json output. 30 | This is on by default when sending from stdin or receiving to stdout. 31 | - exclude-file-contents 32 | Excludes the contents of files in the json output. 33 | This is on by default when sending or receiving to or from disk.`) 34 | flags.Bool(fs, "output.qrCode", "qr-code", "Print a QR code of a URL that the server can be reached at") 35 | flags.Bool(fs, "output.noColor", "no-color", "Disable color output") 36 | 37 | cobra.AddTemplateFunc("outputFlags", func() *pflag.FlagSet { 38 | return fs 39 | }) 40 | cobra.AddTemplateFunc("outputClientFlags", func() *pflag.FlagSet { 41 | fs := pflag.NewFlagSet("Output Flags", pflag.ExitOnError) 42 | fs.BoolP("quiet", "q", false, "Disable all output except for received data") 43 | fs.StringP("output", "o", "", `Set output format. Valid formats are: json[=opts]. 44 | Valid json opts are: 45 | - compact 46 | Disables tabbed, pretty printed json. 47 | - include-file-contents 48 | Includes the contents of files in the json output. 49 | This is on by default when sending from stdin or receiving to stdout. 50 | - exclude-file-contents 51 | Excludes the contents of files in the json output. 52 | This is on by default when sending or receiving to or from disk.`) 53 | fs.Bool("no-color", false, "Disable color output") 54 | return fs 55 | }) 56 | } 57 | 58 | func (c *Output) validate() error { 59 | if c.Format == "" { 60 | return nil 61 | } 62 | parts := strings.Split(c.Format, "=") 63 | if len(parts) == 0 || 2 < len(parts) { 64 | return fmt.Errorf("invalid output format: %s", c.Format) 65 | } 66 | format := parts[0] 67 | if format != "json" { 68 | return fmt.Errorf("invalid output format: %s", c.Format) 69 | } 70 | 71 | opts := []string{} 72 | if len(parts) == 2 { 73 | opts = strings.Split(parts[1], ",") 74 | } 75 | 76 | for _, opt := range opts { 77 | if opt != "compact" && opt != "include-file-contents" && opt != "exclude-file-contents" { 78 | return fmt.Errorf("invalid output format option: %s", opt) 79 | } 80 | } 81 | 82 | return nil 83 | } 84 | -------------------------------------------------------------------------------- /v2/pkg/configuration/server.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/forestnode-io/oneshot/v2/pkg/flags" 8 | "github.com/spf13/cobra" 9 | "github.com/spf13/pflag" 10 | ) 11 | 12 | type Server struct { 13 | Host string `mapstructure:"host" yaml:"host"` 14 | Port int `mapstructure:"port" yaml:"port"` 15 | Timeout time.Duration `mapstructure:"timeout" yaml:"timeout"` 16 | AllowBots bool `mapstructure:"allowBots" yaml:"allowBots"` 17 | MaxReadSize string `mapstructure:"maxReadSize" yaml:"maxReadSize"` 18 | ExitOnFail bool `mapstructure:"exitOnFail" yaml:"exitOnFail"` 19 | TLSCert string `mapstructure:"tlsCert" yaml:"tlsCert"` 20 | TLSKey string `mapstructure:"tlsKey" yaml:"tlsKey"` 21 | } 22 | 23 | func setServerFlags(cmd *cobra.Command) { 24 | fs := pflag.NewFlagSet("Server Flags", pflag.ExitOnError) 25 | defer cmd.PersistentFlags().AddFlagSet(fs) 26 | 27 | flags.String(fs, "server.host", "host", "Host to listen on") 28 | flags.IntP(fs, "server.port", "port", "p", "Port to listen on") 29 | flags.Duration(fs, "server.timeout", "timeout", `How long to wait for a connection to be established before timing out. 30 | A value of 0 will cause oneshot to wait indefinitely.`) 31 | flags.Bool(fs, "server.allowbots", "allow-bots", "Allow bots access") 32 | flags.String(fs, "server.maxreadsize", "max-read-size", `Maximum read size for incoming request bodies. A value of zero will cause oneshot to read until EOF. 33 | Format is a number followed by a unit of measurement. 34 | Valid units are: b, B, 35 | Kb, KB, KiB, 36 | Mb, MB, MiB, 37 | Gb, GB, GiB, 38 | Tb, TB, TiB 39 | Example: 1.5GB`) 40 | flags.Bool(fs, "server.exitonfail", "exit-on-fail", "Exit after a failed transfer, without waiting for a new connection") 41 | flags.String(fs, "server.tlscert", "tls-cert", "Path to TLS certificate") 42 | flags.String(fs, "server.tlskey", "tls-key", "Path to TLS key") 43 | 44 | cobra.AddTemplateFunc("serverFlags", func() *pflag.FlagSet { 45 | return fs 46 | }) 47 | } 48 | 49 | func (c *Server) validate() error { 50 | if c.Port < 1 || c.Port > 65535 { 51 | return fmt.Errorf("invalid port: %d", c.Port) 52 | } 53 | 54 | if c.TLSCert != "" && c.TLSKey == "" { 55 | return fmt.Errorf("tls-key is required when tls-cert is set") 56 | } 57 | if c.TLSKey != "" && c.TLSCert == "" { 58 | return fmt.Errorf("tls-cert is required when tls-key is set") 59 | } 60 | 61 | return nil 62 | } 63 | -------------------------------------------------------------------------------- /v2/pkg/configuration/util.go: -------------------------------------------------------------------------------- 1 | package configuration 2 | 3 | import ( 4 | "errors" 5 | "regexp" 6 | "strconv" 7 | "strings" 8 | ) 9 | 10 | var sizeRe = regexp.MustCompile(`([1-9]\d*)([kmgtKMGT]?[i]?[bB])`) 11 | 12 | func ParseSizeString(s string) (int64, error) { 13 | if s == "" || s == "0" { 14 | return 0, nil 15 | } 16 | 17 | const ( 18 | k = 1000 19 | ki = 1024 20 | ) 21 | 22 | parts := sizeRe.FindStringSubmatch(s) 23 | if len(parts) != 3 { 24 | return 0, errors.New("invalid size") 25 | } 26 | ns := parts[1] 27 | units := parts[2] 28 | 29 | n, err := strconv.ParseInt(ns, 10, 64) 30 | if err != nil { 31 | return 0, err 32 | } 33 | 34 | var ( 35 | mult int64 = 1 36 | usingBits = false 37 | ) 38 | switch len(units) { 39 | case 1: 40 | if units[0] == 'b' { 41 | usingBits = true 42 | } 43 | case 2: 44 | if units[1] == 'b' { 45 | usingBits = true 46 | } 47 | 48 | order := strings.ToLower(string(units[0])) 49 | switch order { 50 | case "k": 51 | mult = k 52 | case "m": 53 | mult = k * k 54 | case "g": 55 | mult = k * k * k 56 | case "t": 57 | mult = k * k * k * k 58 | } 59 | case 3: 60 | if units[2] == 'b' { 61 | usingBits = true 62 | } 63 | 64 | order := strings.ToLower(string(units[0])) 65 | switch order { 66 | case "k": 67 | mult = ki 68 | case "m": 69 | mult = ki * ki 70 | case "g": 71 | mult = ki * ki * ki 72 | case "t": 73 | mult = ki * ki * ki * ki 74 | } 75 | } 76 | 77 | if usingBits { 78 | if 1 < mult { 79 | mult /= 8 80 | } else { 81 | bumpByOne := n%8 != 0 82 | n /= 8 83 | if bumpByOne { 84 | n += 1 85 | } 86 | } 87 | } 88 | 89 | return mult * n, nil 90 | } 91 | -------------------------------------------------------------------------------- /v2/pkg/events/events.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "context" 5 | ) 6 | 7 | // Event represents events in oneshot that should be communicated to the user. 8 | type Event interface { 9 | _event 10 | } 11 | 12 | type _event interface { 13 | isEvent() 14 | } 15 | 16 | type SetEventChanFunc func(context.Context, chan Event) 17 | 18 | func RegisterEventListener(ctx context.Context, f SetEventChanFunc) { 19 | b := bndl(ctx) 20 | f(ctx, b.eventsChan) 21 | } 22 | 23 | type ClientDisconnected struct { 24 | Err error 25 | } 26 | 27 | func (ClientDisconnected) isEvent() {} 28 | 29 | func (c ClientDisconnected) Error() string { 30 | return c.Err.Error() 31 | } 32 | 33 | type HTTPRequestBody func() ([]byte, error) 34 | 35 | func (HTTPRequestBody) isEvent() {} 36 | 37 | func WithEvents(ctx context.Context) context.Context { 38 | ctx, cancel := context.WithCancel(ctx) 39 | b := bundle{ 40 | eventsChan: make(chan Event, 1), 41 | cancel: cancel, 42 | exitCode: -1, 43 | } 44 | 45 | go func() { 46 | <-ctx.Done() 47 | close(b.eventsChan) 48 | }() 49 | ctx = context.WithValue(ctx, bundleKey{}, &b) 50 | 51 | return ctx 52 | } 53 | 54 | func eventChan(ctx context.Context) chan Event { 55 | return bndl(ctx).eventsChan 56 | } 57 | 58 | func Success(ctx context.Context) { 59 | b := bndl(ctx) 60 | b.success = true 61 | } 62 | 63 | func Succeeded(ctx context.Context) bool { 64 | return bndl(ctx).success 65 | } 66 | 67 | func Raise(ctx context.Context, e Event) { 68 | eventChan(ctx) <- e 69 | } 70 | 71 | func Stop(ctx context.Context) { 72 | b := bndl(ctx) 73 | b.cancel() 74 | } 75 | 76 | type bundleKey struct{} 77 | type bundle struct { 78 | eventsChan chan Event 79 | err error 80 | success bool 81 | cancel func() 82 | exitCode int 83 | } 84 | 85 | func bndl(ctx context.Context) *bundle { 86 | b, ok := ctx.Value(bundleKey{}).(*bundle) 87 | if !ok { 88 | panic("missing event bundle missing from context") 89 | } 90 | return b 91 | } 92 | 93 | func GetCancellationError(ctx context.Context) error { 94 | b := bndl(ctx) 95 | return b.err 96 | } 97 | 98 | func SetExitCode(ctx context.Context, code int) { 99 | b := bndl(ctx) 100 | b.exitCode = code 101 | } 102 | 103 | func GetExitCode(ctx context.Context) int { 104 | b := bndl(ctx) 105 | return b.exitCode 106 | } 107 | -------------------------------------------------------------------------------- /v2/pkg/events/exitCodes.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | const ( 4 | // ExitCodeSuccess is the exit code for a successful run. 5 | ExitCodeSuccess = iota 6 | ExitCodeGenericFailure 7 | ExitCodeTimeoutFailure 8 | ) 9 | -------------------------------------------------------------------------------- /v2/pkg/events/file.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "time" 5 | ) 6 | 7 | // File represents the file sent over by the client in the case 8 | // of a transfer (send or receive) 9 | type File struct { 10 | // Name is the name presented by the client. 11 | // This is not necessarily the name that the file will be save with. 12 | Name string `json:",omitempty"` 13 | // Path is the path the file was saved to. 14 | // This will only be set if the file was actually saved to disk. 15 | Path string `json:",omitempty"` 16 | MIME string `json:",omitempty"` 17 | // Size is the size of the file in bytes. 18 | // This may not always be set. 19 | Size int64 `json:",omitempty"` 20 | 21 | // TransferSize is the total size oneshot has read in / out. 22 | // For a successful file transfer, this will be equal to the size of the file. 23 | TransferSize int64 `json:",omitempty"` 24 | TransferStartTime time.Time `json:",omitempty"` 25 | TransferEndTime time.Time `json:",omitempty"` 26 | TransferDuration time.Duration `json:",omitempty"` 27 | /// TransferRate is given in bytes / second 28 | TransferRate int64 `json:",omitempty"` 29 | 30 | Content any `json:",omitempty"` 31 | } 32 | 33 | // ComputeTransferFields handles calculating field values that could not be 34 | // obtained until after the transfer (successful or not), such as the duration. 35 | func (f *File) ComputeTransferFields() { 36 | if f == nil { 37 | return 38 | } 39 | 40 | f.TransferDuration = f.TransferEndTime.Sub(f.TransferStartTime) 41 | f.TransferRate = 1000 * 1000 * 1000 * f.TransferSize / int64(f.TransferDuration) 42 | } 43 | 44 | func (*File) isEvent() {} 45 | -------------------------------------------------------------------------------- /v2/pkg/events/httpRequest.go: -------------------------------------------------------------------------------- 1 | package events 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "net/http" 7 | ) 8 | 9 | type HTTPRequest struct { 10 | Method string `json:",omitempty"` 11 | RequestURI string `json:",omitempty"` 12 | Path string `json:",omitempty"` 13 | Query map[string][]string `json:",omitempty"` 14 | Protocol string `json:",omitempty"` 15 | Header map[string][]string `json:",omitempty"` 16 | Host string `json:",omitempty"` 17 | Trailer map[string][]string `json:",omitempty"` 18 | RemoteAddr string `json:",omitempty"` 19 | 20 | Body any `json:",omitempty"` 21 | 22 | body func() ([]byte, error) `json:"-"` 23 | } 24 | 25 | // ReadBody reads in the http requests body by calling body() if its not nil. 26 | // the body func just reads in a buffered copy of the body; it will have already 27 | // been read from the client point of view. 28 | func (hr *HTTPRequest) ReadBody() error { 29 | bf := hr.body 30 | if bf == nil || hr.Body != nil { 31 | return nil 32 | } 33 | 34 | body, err := bf() 35 | if err != nil { 36 | return err 37 | } 38 | 39 | if len(body) == 0 { 40 | return nil 41 | } 42 | 43 | hr.Body = body 44 | 45 | return nil 46 | } 47 | 48 | // newHTTPRequest_WithBody replaces the requests body with a tee reader that copies the data into a byte buffer. 49 | // This allows for the body to be written out later in a report should we need to. 50 | func NewHTTPRequest(r *http.Request, wrapper func(io.Reader) io.Reader) *HTTPRequest { 51 | ht := HTTPRequest{ 52 | Method: r.Method, 53 | RequestURI: r.RequestURI, 54 | Path: r.URL.Path, 55 | Query: r.URL.Query(), 56 | Protocol: r.Proto, 57 | Header: r.Header.Clone(), 58 | Host: r.Host, 59 | Trailer: r.Trailer.Clone(), 60 | RemoteAddr: r.RemoteAddr, 61 | } 62 | 63 | if r.Body == nil { 64 | return &ht 65 | } 66 | 67 | buf := bytes.NewBuffer(nil) 68 | r.Body = io.NopCloser(io.TeeReader(r.Body, buf)) 69 | ht.body = func() ([]byte, error) { 70 | if wrapper != nil { 71 | return io.ReadAll(wrapper(buf)) 72 | } else { 73 | return buf.Bytes(), nil 74 | } 75 | } 76 | 77 | return &ht 78 | } 79 | 80 | func (*HTTPRequest) isEvent() {} 81 | 82 | type HTTPResponse struct { 83 | StatusCode int `json:",omitempty"` 84 | Header http.Header `json:",omitempty"` 85 | Body any `json:",omitempty"` 86 | } 87 | 88 | func (hr *HTTPResponse) ReadBody() error { 89 | if hr.Body != nil { 90 | return nil 91 | } 92 | 93 | bf, ok := hr.Body.(func() []byte) 94 | if !ok { 95 | return nil 96 | } 97 | 98 | body := bf() 99 | if len(body) == 0 { 100 | hr.Body = nil 101 | return nil 102 | } 103 | 104 | hr.Body = body 105 | 106 | return nil 107 | } 108 | 109 | func (*HTTPResponse) isEvent() {} 110 | -------------------------------------------------------------------------------- /v2/pkg/file/isDirWritable_unix.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package file 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | "os/user" 9 | "syscall" 10 | ) 11 | 12 | func _isDirWritable(path string, info os.FileInfo) error { 13 | const ( 14 | // Owner Group Other 15 | // rwx rwx rwx 16 | bmOthers = 0b000000010 // 000 000 010 17 | bmGroup = 0b000010000 // 000 010 000 18 | bmOwner = 0b010000000 // 010 000 000 19 | ) 20 | var mode = info.Mode() 21 | 22 | // check if writable by others 23 | if mode&bmOthers != 0 { 24 | return nil 25 | } 26 | 27 | stat := info.Sys().(*syscall.Stat_t) 28 | usr, err := user.Current() 29 | if err != nil { 30 | return err 31 | } 32 | 33 | // check if writable by group 34 | if mode&bmGroup != 0 { 35 | gid := fmt.Sprint(stat.Gid) 36 | gids, err := usr.GroupIds() 37 | if err != nil { 38 | return err 39 | } 40 | for _, g := range gids { 41 | if g == gid { 42 | return nil 43 | } 44 | } 45 | } 46 | 47 | // check if writable by owner 48 | if mode&bmOwner != 0 { 49 | uid := fmt.Sprint(stat.Uid) 50 | if uid == usr.Uid { 51 | return nil 52 | } 53 | } 54 | 55 | return fmt.Errorf("%s: permission denied %+v - %+v", path, int(mode.Perm()), bmOwner) 56 | } 57 | -------------------------------------------------------------------------------- /v2/pkg/file/isDirWritable_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package file 4 | 5 | import ( 6 | "fmt" 7 | "os" 8 | "path/filepath" 9 | "time" 10 | ) 11 | 12 | func _isDirWritable(path string, info os.FileInfo) error { 13 | testFileName := fmt.Sprintf("oneshot%d", time.Now().Unix()) 14 | file, err := os.Create(filepath.Join(path, testFileName)) 15 | if err != nil { 16 | return err 17 | } 18 | file.Close() 19 | os.Remove(file.Name()) 20 | return nil 21 | } 22 | -------------------------------------------------------------------------------- /v2/pkg/file/tar.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | "archive/tar" 5 | "compress/gzip" 6 | "fmt" 7 | "io" 8 | "os" 9 | "path/filepath" 10 | "strings" 11 | ) 12 | 13 | func tarball(compress bool, paths []string, w io.Writer) error { 14 | var tw *tar.Writer 15 | if compress { 16 | gw := gzip.NewWriter(w) 17 | defer gw.Close() 18 | tw = tar.NewWriter(gw) 19 | } else { 20 | tw = tar.NewWriter(w) 21 | } 22 | defer tw.Close() 23 | 24 | formatName := func(name string) string { 25 | if name == "" { 26 | return "" 27 | } 28 | 29 | // needed for windows 30 | name = strings.ReplaceAll(name, `\`, `/`) 31 | if string(name[0]) == `/` { 32 | if len(name) == 1 { 33 | return "" 34 | } 35 | name = name[1:] 36 | } 37 | return name 38 | } 39 | 40 | walkFunc := func(path string, buf []byte) func(string, os.FileInfo, error) error { 41 | dir := filepath.Dir(path) 42 | return func(fp string, info os.FileInfo, err error) error { 43 | if err != nil { 44 | return err 45 | } 46 | 47 | var link string 48 | 49 | if info.Mode()&os.ModeSymlink != 0 { 50 | link, err = os.Readlink(fp) 51 | if err != nil { 52 | return fmt.Errorf("failed to read symlink %s: %w", fp, err) 53 | } 54 | } 55 | 56 | header, err := tar.FileInfoHeader(info, link) 57 | if err != nil { 58 | return err 59 | } 60 | 61 | header.Name = strings.TrimPrefix(fp, dir) 62 | header.Name = formatName(header.Name) 63 | if header.Name == "" { 64 | return nil 65 | } 66 | 67 | if err = tw.WriteHeader(header); err != nil { 68 | return err 69 | } 70 | 71 | if !info.Mode().IsRegular() { 72 | return nil 73 | } 74 | 75 | fh, err := os.Open(fp) 76 | if err != nil { 77 | return err 78 | } 79 | defer fh.Close() 80 | 81 | if _, err = io.CopyBuffer(tw, fh, buf); err != nil { 82 | return err 83 | } 84 | 85 | return nil 86 | } 87 | } 88 | 89 | // Loop over files to be archived 90 | for _, path := range paths { 91 | info, err := os.Stat(path) 92 | if err != nil { 93 | return err 94 | } 95 | if info.IsDir() { // Archiving a directory; needs to be walked 96 | buf := make([]byte, 32*1024) 97 | err := filepath.Walk(path, walkFunc(path, buf)) 98 | if err != nil { 99 | return err 100 | } 101 | } else { // Archiving a single file or symlink 102 | size := info.Size() 103 | if size == 0 { 104 | size = 32 * 1024 105 | } 106 | buf := make([]byte, size) 107 | if err = walkFunc(path, buf)(path, info, nil); err != nil { 108 | return err 109 | } 110 | } 111 | } 112 | 113 | return nil 114 | } 115 | -------------------------------------------------------------------------------- /v2/pkg/file/zip.go: -------------------------------------------------------------------------------- 1 | package file 2 | 3 | import ( 4 | z "archive/zip" 5 | "io" 6 | "os" 7 | "path/filepath" 8 | "strings" 9 | ) 10 | 11 | func zip(paths []string, w io.Writer) error { 12 | zw := z.NewWriter(w) 13 | defer zw.Close() 14 | 15 | formatName := func(name string) string { 16 | // needed for windows 17 | name = strings.ReplaceAll(name, `\`, `/`) 18 | if string(name[0]) == `/` { 19 | name = name[1:] 20 | } 21 | return name 22 | } 23 | 24 | writeFile := func(path, name string, info os.FileInfo) error { 25 | zFile, err := zw.Create(name) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | currFile, err := os.Open(path) 31 | if err != nil { 32 | return err 33 | } 34 | 35 | _, err = io.Copy(zFile, currFile) 36 | currFile.Close() 37 | if err != nil { 38 | return err 39 | } 40 | 41 | return nil 42 | } 43 | 44 | walkFunc := func(path string) func(string, os.FileInfo, error) error { 45 | dir := filepath.Dir(path) 46 | return func(fp string, info os.FileInfo, err error) error { 47 | if info.IsDir() { 48 | return nil 49 | } else if err != nil { 50 | return err 51 | } 52 | 53 | name := strings.TrimPrefix(fp, dir) 54 | name = formatName(name) 55 | 56 | if err = writeFile(fp, name, info); err != nil { 57 | return err 58 | } 59 | 60 | return nil 61 | } 62 | } 63 | 64 | // Loop over files to be archived 65 | for _, path := range paths { 66 | info, err := os.Stat(path) 67 | if err != nil { 68 | return err 69 | } 70 | if !info.IsDir() { // Archiving a file 71 | name := filepath.Base(path) 72 | name = formatName(name) 73 | 74 | err = writeFile(path, name, info) 75 | if err != nil { 76 | return err 77 | } 78 | } else { // Archiving a directory; needs to be walked 79 | err := filepath.Walk(path, walkFunc(path)) 80 | if err != nil { 81 | return err 82 | } 83 | } 84 | } 85 | 86 | return nil 87 | } 88 | -------------------------------------------------------------------------------- /v2/pkg/flags/flags.go: -------------------------------------------------------------------------------- 1 | package flags 2 | 3 | import ( 4 | "github.com/spf13/pflag" 5 | "github.com/spf13/viper" 6 | ) 7 | 8 | func Bool(fs *pflag.FlagSet, key, name, usage string) { 9 | defValue := viper.GetBool(key) 10 | fs.Bool(name, defValue, usage) 11 | viper.BindPFlag(key, fs.Lookup(name)) 12 | } 13 | 14 | func BoolP(fs *pflag.FlagSet, key, name, shorthand, usage string) { 15 | defValue := viper.GetBool(key) 16 | fs.BoolP(name, shorthand, defValue, usage) 17 | viper.BindPFlag(key, fs.Lookup(name)) 18 | } 19 | 20 | func String(fs *pflag.FlagSet, key, name, usage string) { 21 | defValue := viper.GetString(key) 22 | fs.String(name, defValue, usage) 23 | viper.BindPFlag(key, fs.Lookup(name)) 24 | } 25 | 26 | func StringP(fs *pflag.FlagSet, key, name, shorthand, usage string) { 27 | defValue := viper.GetString(key) 28 | fs.StringP(name, shorthand, defValue, usage) 29 | viper.BindPFlag(key, fs.Lookup(name)) 30 | } 31 | 32 | func StringSlice(fs *pflag.FlagSet, key, name, usage string) { 33 | defValue := viper.GetStringSlice(key) 34 | fs.StringSlice(name, defValue, usage) 35 | viper.BindPFlag(key, fs.Lookup(name)) 36 | } 37 | 38 | func StringSliceP(fs *pflag.FlagSet, key, name, shorthand, usage string) { 39 | defValue := viper.GetStringSlice(key) 40 | fs.StringSliceP(name, shorthand, defValue, usage) 41 | viper.BindPFlag(key, fs.Lookup(name)) 42 | } 43 | 44 | func Int(fs *pflag.FlagSet, key, name, usage string) { 45 | defValue := viper.GetInt(key) 46 | fs.Int(name, defValue, usage) 47 | viper.BindPFlag(key, fs.Lookup(name)) 48 | } 49 | 50 | func IntP(fs *pflag.FlagSet, key, name, shorthand, usage string) { 51 | defValue := viper.GetInt(key) 52 | fs.IntP(name, shorthand, defValue, usage) 53 | viper.BindPFlag(key, fs.Lookup(name)) 54 | } 55 | 56 | func Duration(fs *pflag.FlagSet, key, name, usage string) { 57 | defValue := viper.GetDuration(key) 58 | fs.Duration(name, defValue, usage) 59 | viper.BindPFlag(key, fs.Lookup(name)) 60 | } 61 | -------------------------------------------------------------------------------- /v2/pkg/log/log.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | 10 | "github.com/rs/zerolog" 11 | "gopkg.in/natefinch/lumberjack.v2" 12 | ) 13 | 14 | var ( 15 | log = zerolog.New(io.Discard) 16 | ) 17 | 18 | func Logging(ctx context.Context) (context.Context, func(), error) { 19 | cleanup := func() {} 20 | logDir := os.Getenv("ONESHOT_LOG_DIR") 21 | if logDir == "" { 22 | if cacheDir, _ := os.UserCacheDir(); cacheDir != "" { 23 | logDir = filepath.Join(cacheDir, "oneshot") 24 | if err := os.Mkdir(logDir, os.ModeDir|0700); err != nil { 25 | if !os.IsExist(err) { 26 | logDir = "" 27 | } 28 | } 29 | } 30 | } 31 | 32 | var output io.Writer 33 | if logDir != "" { 34 | logPath := filepath.Join(logDir, "oneshot.log") 35 | lj := lumberjack.Logger{ 36 | Filename: logPath, 37 | MaxSize: 500, // megabytes 38 | } 39 | output = &lj 40 | cleanup = func() { 41 | lj.Close() 42 | } 43 | } else { 44 | output = io.Discard 45 | } 46 | 47 | if os.Getenv("ONESHOT_LOG_STDERR") != "" { 48 | output = os.Stderr 49 | } 50 | 51 | var ( 52 | levelString = os.Getenv("ONESHOT_LOG_LEVEL") 53 | level = zerolog.InfoLevel 54 | err error 55 | ) 56 | if levelString != "" { 57 | level, err = zerolog.ParseLevel(levelString) 58 | if err != nil { 59 | return ctx, cleanup, fmt.Errorf("unable to parse log level from ONESHOT_LOG_LEVEL: %s", err.Error()) 60 | } 61 | } 62 | 63 | logContext := zerolog.New(output). 64 | Level(level). 65 | With(). 66 | Timestamp() 67 | if level == zerolog.DebugLevel { 68 | logContext = logContext. 69 | Stack(). 70 | Caller() 71 | } 72 | 73 | log = logContext.Logger() 74 | 75 | ctx = log.WithContext(ctx) 76 | return ctx, cleanup, nil 77 | } 78 | 79 | func Logger() *zerolog.Logger { 80 | return &log 81 | } 82 | -------------------------------------------------------------------------------- /v2/pkg/net/http/http.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "fmt" 5 | "net/http" 6 | "strings" 7 | ) 8 | 9 | func HeaderFromStringSlice(s []string) (http.Header, error) { 10 | h := make(http.Header) 11 | for _, hs := range s { 12 | var ( 13 | parts = strings.SplitN(hs, "=", 2) 14 | k = parts[0] 15 | v = "" 16 | ) 17 | 18 | if len(parts) != 2 { 19 | return nil, fmt.Errorf("invalid header, must be of the form =: %s", hs) 20 | } 21 | 22 | v = parts[1] 23 | var vs = h[k] 24 | if vs == nil { 25 | vs = make([]string, 0) 26 | } 27 | vs = append(vs, v) 28 | h[k] = vs 29 | } 30 | 31 | return h, nil 32 | } 33 | -------------------------------------------------------------------------------- /v2/pkg/net/listenerTimer.go: -------------------------------------------------------------------------------- 1 | package network 2 | 3 | import ( 4 | "net" 5 | "time" 6 | ) 7 | 8 | type ListenerTimer struct { 9 | net.Listener 10 | timer *time.Timer 11 | C <-chan time.Time 12 | } 13 | 14 | func NewListenerTimer(l net.Listener, d time.Duration) *ListenerTimer { 15 | var ll ListenerTimer 16 | ll.Listener = l 17 | ll.timer = time.NewTimer(d) 18 | ll.C = ll.timer.C 19 | return &ll 20 | } 21 | 22 | func (l *ListenerTimer) Accept() (net.Conn, error) { 23 | conn, err := l.Listener.Accept() 24 | 25 | if l.timer != nil && err == nil { 26 | if !l.timer.Stop() { 27 | <-l.timer.C 28 | } 29 | l.timer = nil 30 | } 31 | 32 | return conn, err 33 | } 34 | 35 | func (l *ListenerTimer) Close() error { 36 | return l.Listener.Close() 37 | } 38 | 39 | func (l *ListenerTimer) Addr() net.Addr { 40 | return l.Listener.Addr() 41 | } 42 | -------------------------------------------------------------------------------- /v2/pkg/net/upnp-igd/request.go: -------------------------------------------------------------------------------- 1 | package upnpigd 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "io" 7 | "net/http" 8 | "strings" 9 | ) 10 | 11 | type request struct { 12 | url string 13 | body string 14 | header http.Header 15 | 16 | service string 17 | function string 18 | } 19 | 20 | func (r *request) do(ctx context.Context, client *http.Client) ([]byte, error) { 21 | payload := strings.NewReader(envelope(r.body)) 22 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, r.url, payload) 23 | if err != nil { 24 | return nil, fmt.Errorf("error creating request: %w", err) 25 | } 26 | addSOAPRequestHeaders(r.header, r.service, r.function) 27 | req.Header = r.header 28 | 29 | resp, err := client.Do(req) 30 | if err != nil { 31 | return nil, fmt.Errorf("error making http call to service: %w", err) 32 | } 33 | 34 | body, err := io.ReadAll(resp.Body) 35 | if err != nil { 36 | return nil, fmt.Errorf("error reading response body: %w", err) 37 | } 38 | _ = resp.Body.Close() 39 | 40 | if 400 <= resp.StatusCode { 41 | return nil, fmt.Errorf("got error status code from service: %d %s", resp.StatusCode, resp.Status) 42 | } 43 | 44 | return body, nil 45 | } 46 | 47 | func addSOAPRequestHeaders(h http.Header, service, function string) { 48 | h.Set("Content-Type", `text/xml; charset="utf-8"`) 49 | h["SOAPAction"] = []string{fmt.Sprintf(`"%s#%s"`, service, function)} 50 | h.Set("Connection", "close") 51 | h.Set("Cache-Control", "no-cache") 52 | h.Set("Pragma", "no-cache") 53 | } 54 | 55 | func envelope(payload string) string { 56 | tmplt := ` 57 | 58 | %s 59 | 60 | ` 61 | 62 | return fmt.Sprintf(tmplt, payload) 63 | } 64 | -------------------------------------------------------------------------------- /v2/pkg/net/upnp-igd/upnp.go: -------------------------------------------------------------------------------- 1 | package upnpigd 2 | 3 | import "net" 4 | 5 | type service struct { 6 | ID string `xml:"serviceId"` 7 | Type string `xml:"serviceType"` 8 | ControlURL string `xml:"controlURL"` 9 | } 10 | 11 | type device struct { 12 | DeviceType string `xml:"deviceType"` 13 | FriendlyName string `xml:"friendlyName"` 14 | Devices []device `xml:"deviceList>device"` 15 | Services []service `xml:"serviceList>service"` 16 | } 17 | 18 | type root struct { 19 | Device device `xml:"device"` 20 | } 21 | 22 | const ( 23 | URN_InterntGatewayDevice1 = "urn:schemas-upnp-org:device:InternetGatewayDevice:1" 24 | URN_WANDevice1 = "urn:schemas-upnp-org:device:WANDevice:1" 25 | URN_WANConnectionDevice1 = "urn:schemas-upnp-org:device:WANConnectionDevice:1" 26 | URN_WANIPConnection1 = "urn:schemas-upnp-org:service:WANIPConnection:1" 27 | URN_WANPPPConnection1 = "urn:schemas-upnp-org:service:WANPPPConnection:1" 28 | 29 | URN_InternetGatewayDevice2 = "urn:schemas-upnp-org:device:InternetGatewayDevice:2" 30 | URN_WANDevice2 = "urn:schemas-upnp-org:device:WANDevice:2" 31 | URN_WANConnectionDevice2 = "urn:schemas-upnp-org:device:WANConnectionDevice:2" 32 | URN_WANIPConnection2 = "urn:schemas-upnp-org:service:WANIPConnection:2" 33 | URN_WANPPPConnection2 = "urn:schemas-upnp-org:service:WANPPPConnection:2" 34 | ) 35 | 36 | var ( 37 | URN_InternetGatewayDevices = []string{ 38 | URN_InterntGatewayDevice1, 39 | URN_InternetGatewayDevice2, 40 | } 41 | URN_WANConnections1 = []string{ 42 | URN_WANIPConnection1, 43 | URN_WANPPPConnection1, 44 | } 45 | URN_WANConnections2 = []string{ 46 | URN_WANIPConnection2, 47 | URN_WANPPPConnection2, 48 | } 49 | ) 50 | 51 | var MulticastADDR = net.UDPAddr{ 52 | IP: net.IPv4(239, 255, 255, 250), 53 | Port: 1900, 54 | } 55 | -------------------------------------------------------------------------------- /v2/pkg/net/webrtc/client/flowControlledWriter.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import "io" 4 | 5 | type flowControlledWriter struct { 6 | w io.Writer 7 | bufferedAmount func() int 8 | maxBufferedAmount int 9 | continueChan chan struct{} 10 | } 11 | 12 | func (w *flowControlledWriter) Write(p []byte) (int, error) { 13 | n, err := w.w.Write(p) 14 | 15 | // if we are over the max buffered amount, wait for the continue channel 16 | if ba := w.bufferedAmount(); w.maxBufferedAmount < ba+n { 17 | <-w.continueChan 18 | } 19 | 20 | return n, err 21 | } 22 | -------------------------------------------------------------------------------- /v2/pkg/net/webrtc/sdp/sdp.go: -------------------------------------------------------------------------------- 1 | package sdp 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "time" 7 | 8 | "github.com/pion/webrtc/v3" 9 | ) 10 | 11 | type Offer string 12 | 13 | func OfferFromJSON(data []byte) (Offer, error) { 14 | sdp := webrtc.SessionDescription{} 15 | if err := json.Unmarshal(data, &sdp); err != nil { 16 | return "", err 17 | } 18 | if sdp.Type != webrtc.SDPTypeOffer { 19 | return "", fmt.Errorf("invalid SDP type: %s", sdp.Type) 20 | } 21 | return Offer(sdp.SDP), nil 22 | } 23 | 24 | func (o Offer) MarshalJSON() ([]byte, error) { 25 | sdp, err := o.WebRTCSessionDescription() 26 | if err != nil { 27 | return nil, err 28 | } 29 | return json.Marshal(sdp) 30 | } 31 | 32 | func (s Offer) WebRTCSessionDescription() (webrtc.SessionDescription, error) { 33 | sdp := webrtc.SessionDescription{ 34 | Type: webrtc.SDPTypeOffer, 35 | SDP: string(s), 36 | } 37 | _, err := sdp.Unmarshal() 38 | return sdp, err 39 | } 40 | 41 | type Answer string 42 | 43 | func AnswerFromJSON(data []byte) (Answer, error) { 44 | sdp := webrtc.SessionDescription{} 45 | if err := json.Unmarshal(data, &sdp); err != nil { 46 | return "", err 47 | } 48 | if sdp.Type != webrtc.SDPTypeAnswer { 49 | return "", fmt.Errorf("invalid SDP type: %s", sdp.Type) 50 | } 51 | return Answer(sdp.SDP), nil 52 | } 53 | 54 | func (s Answer) WebRTCSessionDescription() (webrtc.SessionDescription, error) { 55 | sdp := webrtc.SessionDescription{ 56 | Type: webrtc.SDPTypeAnswer, 57 | SDP: string(s), 58 | } 59 | _, err := sdp.Unmarshal() 60 | return sdp, err 61 | } 62 | 63 | func (s Answer) MarshalJSON() ([]byte, error) { 64 | sdp, err := s.WebRTCSessionDescription() 65 | if err != nil { 66 | return nil, err 67 | } 68 | return json.Marshal(sdp) 69 | } 70 | 71 | const PingWindowDuration = 500 * time.Millisecond 72 | -------------------------------------------------------------------------------- /v2/pkg/net/webrtc/sdp/signallers/fileClientSignaller.go: -------------------------------------------------------------------------------- 1 | package signallers 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/pion/webrtc/v3" 10 | "github.com/forestnode-io/oneshot/v2/pkg/net/webrtc/sdp" 11 | ) 12 | 13 | type fileClientSignaller struct { 14 | offer sdp.Offer 15 | answerFilePath string 16 | } 17 | 18 | func NewFileClientSignaller(offerFilePath, answerFilePath string) (ClientSignaller, string, error) { 19 | offerFileBytes, err := os.ReadFile(offerFilePath) 20 | if err != nil { 21 | return nil, "", fmt.Errorf("failed to read offer file: %w", err) 22 | } 23 | 24 | wsdp := webrtc.SessionDescription{} 25 | if err := json.Unmarshal(offerFileBytes, &wsdp); err != nil { 26 | return nil, "", fmt.Errorf("failed to unmarshal offer: %w", err) 27 | } 28 | wssdp, err := wsdp.Unmarshal() 29 | if err != nil { 30 | return nil, "", fmt.Errorf("failed to unmarshal offer: %w", err) 31 | } 32 | var bat string 33 | for _, attribute := range wssdp.Attributes { 34 | if attribute.Key == "BasicAuthToken" { 35 | bat = attribute.Value 36 | break 37 | } 38 | } 39 | 40 | return &fileClientSignaller{ 41 | offer: sdp.Offer(wsdp.SDP), 42 | answerFilePath: answerFilePath, 43 | }, bat, nil 44 | } 45 | 46 | func (s *fileClientSignaller) Start(ctx context.Context, offerHandler OfferHandler) error { 47 | answer, err := offerHandler.HandleOffer(ctx, "", s.offer) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | answerJSON, err := answer.MarshalJSON() 53 | if err != nil { 54 | return fmt.Errorf("failed to marshal answer: %w", err) 55 | } 56 | 57 | if err := os.WriteFile(s.answerFilePath, answerJSON, 0644); err != nil { 58 | return fmt.Errorf("failed to write answer file: %w", err) 59 | } 60 | 61 | return nil 62 | } 63 | 64 | func (s *fileClientSignaller) Shutdown() error { 65 | return nil 66 | } 67 | -------------------------------------------------------------------------------- /v2/pkg/net/webrtc/sdp/signallers/serverClientSignaller.go: -------------------------------------------------------------------------------- 1 | package signallers 2 | 3 | import ( 4 | "bytes" 5 | "context" 6 | "encoding/json" 7 | "fmt" 8 | "net/http" 9 | 10 | "github.com/pion/webrtc/v3" 11 | "github.com/forestnode-io/oneshot/v2/pkg/net/webrtc/sdp" 12 | ) 13 | 14 | type serverClientSignaller struct { 15 | url string 16 | httpClient *http.Client 17 | offer sdp.Offer 18 | sessionID string 19 | } 20 | 21 | func NewServerClientSignaller(url, sessionID string, offer *webrtc.SessionDescription, client *http.Client) (ClientSignaller, string, error) { 22 | wssdp, err := offer.Unmarshal() 23 | if err != nil { 24 | return nil, "", fmt.Errorf("failed to unmarshal offer: %w", err) 25 | } 26 | var bat string 27 | for _, attribute := range wssdp.Attributes { 28 | if attribute.Key == "BasicAuthToken" { 29 | bat = attribute.Value 30 | break 31 | } 32 | } 33 | 34 | s := serverClientSignaller{ 35 | url: url, 36 | sessionID: sessionID, 37 | offer: sdp.Offer(offer.SDP), 38 | } 39 | if client == nil { 40 | s.httpClient = http.DefaultClient 41 | } else { 42 | s.httpClient = client 43 | } 44 | 45 | return &s, bat, nil 46 | } 47 | 48 | func (s *serverClientSignaller) Start(ctx context.Context, handler OfferHandler) error { 49 | answer, err := handler.HandleOffer(ctx, s.sessionID, s.offer) 50 | if err != nil { 51 | return fmt.Errorf("failed to handle offer: %w", err) 52 | } 53 | 54 | payload, err := json.Marshal(map[string]any{ 55 | "Answer": string(answer), 56 | "SessionID": s.sessionID, 57 | }) 58 | if err != nil { 59 | return fmt.Errorf("failed to marshal answer: %w", err) 60 | } 61 | req, err := http.NewRequestWithContext(ctx, http.MethodPost, s.url, bytes.NewBuffer(payload)) 62 | if err != nil { 63 | return fmt.Errorf("failed to create request: %w", err) 64 | } 65 | req.Header.Set("Content-Type", "application/json") 66 | req.Header.Set("Accept", "application/json") 67 | 68 | resp, err := s.httpClient.Do(req) 69 | if err != nil { 70 | return fmt.Errorf("http request to signalling server failed: %w", err) 71 | } 72 | 73 | if resp.StatusCode != http.StatusOK { 74 | return fmt.Errorf("http request to signalling server failed: %s", resp.Status) 75 | } 76 | 77 | return nil 78 | } 79 | 80 | func (s *serverClientSignaller) Shutdown() error { 81 | return nil 82 | } 83 | -------------------------------------------------------------------------------- /v2/pkg/net/webrtc/sdp/signallers/signaller.go: -------------------------------------------------------------------------------- 1 | package signallers 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/forestnode-io/oneshot/v2/pkg/net/webrtc/sdp" 7 | "github.com/pion/webrtc/v3" 8 | ) 9 | 10 | // ServerSignaller is an interface that allows a client to connect to a server. 11 | // When a client wants to connect, the session signaller will call on the RequestHandler. 12 | // The session signaller handles the exchange of SDP offers and answers via the AnswerOffer func it 13 | // provides to the RequestHandler. 14 | type ServerSignaller interface { 15 | // Start starts the Signaller and blocks until it is shutdown. 16 | Start(context.Context, RequestHandler) error 17 | // Shutdown stops the Signaller from accepting new requests. 18 | Shutdown() error 19 | } 20 | 21 | type RequestHandler interface { 22 | HandleRequest(context.Context, string, *webrtc.Configuration, AnswerOffer) error 23 | } 24 | 25 | type AnswerOffer func(context.Context, string, sdp.Offer) (sdp.Answer, error) 26 | 27 | // HandleRequest is a function that handles a request from a client. 28 | // A HandleRequest func is called when a client wants to connect to connect to oneshot. 29 | // The HandleRequest func is expected to create a peer and use it create an offer to the client. 30 | // The sdp exchange is transacted via the AnswerOffer arg. 31 | type HandleRequest func(context.Context, string, *webrtc.Configuration, AnswerOffer) error 32 | 33 | func (h HandleRequest) HandleRequest(ctx context.Context, id string, conf *webrtc.Configuration, offer AnswerOffer) error { 34 | return h(ctx, id, conf, offer) 35 | } 36 | 37 | type ClientSignaller interface { 38 | Start(context.Context, OfferHandler) error 39 | Shutdown() error 40 | } 41 | 42 | type OfferHandler interface { 43 | HandleOffer(context.Context, string, sdp.Offer) (sdp.Answer, error) 44 | } 45 | -------------------------------------------------------------------------------- /v2/pkg/net/webrtc/server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "net/http" 7 | "sync" 8 | "time" 9 | 10 | "github.com/forestnode-io/oneshot/v2/pkg/net/webrtc/sdp/signallers" 11 | "github.com/pion/webrtc/v3" 12 | ) 13 | 14 | // Server satisfies the sdp.RequestHandler interface. 15 | // Server acts as a factory for new peer connections when a client request comes in. 16 | type Server struct { 17 | handler http.HandlerFunc 18 | config *webrtc.Configuration 19 | wg sync.WaitGroup 20 | basicAuthToken string 21 | iceGatherTimeout time.Duration 22 | } 23 | 24 | func NewServer(config *webrtc.Configuration, bat string, iceGatherTimeout time.Duration, handler http.HandlerFunc) *Server { 25 | return &Server{ 26 | handler: handler, 27 | config: config, 28 | wg: sync.WaitGroup{}, 29 | basicAuthToken: bat, 30 | iceGatherTimeout: iceGatherTimeout, 31 | } 32 | } 33 | 34 | func (s *Server) Wait() { 35 | s.wg.Wait() 36 | } 37 | 38 | func (s *Server) HandleRequest(ctx context.Context, id string, conf *webrtc.Configuration, answerOfferFunc signallers.AnswerOffer) error { 39 | s.wg.Add(1) 40 | defer s.wg.Done() 41 | 42 | if conf == nil { 43 | conf = s.config 44 | } 45 | // create a new peer connection. 46 | // newPeerConnection does not wait for the peer connection to be established. 47 | pc, pcErrs := newPeerConnection(ctx, id, s.basicAuthToken, s.iceGatherTimeout, answerOfferFunc, conf) 48 | if pc == nil { 49 | err := <-pcErrs 50 | err = fmt.Errorf("unable to create new webRTC peer connection: %w", err) 51 | return err 52 | } 53 | defer pc.Close() 54 | 55 | // create a new data channel. 56 | // newDataChannel waits for the data channel to be established. 57 | d, err := newDataChannel(ctx, s.iceGatherTimeout, pc) 58 | if err != nil { 59 | return fmt.Errorf("unable to create new webRTC data channel: %w", err) 60 | } 61 | defer d.Close() 62 | 63 | var done bool 64 | for !done { 65 | select { 66 | case <-ctx.Done(): 67 | return nil 68 | case e := <-pcErrs: 69 | return fmt.Errorf("error on peer connection: %w", e) 70 | case e := <-d.eventsChan: 71 | if e.err != nil { 72 | return fmt.Errorf("error on data channel: %w", e.err) 73 | } 74 | 75 | w := NewResponseWriter(d) 76 | s.handler(w, e.request) 77 | if w.triggersShutdown { 78 | done = true 79 | } 80 | 81 | if err = w.Flush(); err != nil { 82 | return fmt.Errorf("unable to flush response: %w", err) 83 | } 84 | } 85 | } 86 | 87 | return nil 88 | } 89 | -------------------------------------------------------------------------------- /v2/pkg/net/webrtc/signallingserver/headers/headers.go: -------------------------------------------------------------------------------- 1 | package headers 2 | 3 | const ( 4 | ClosedByUser string = "X-Oneshot-Closed-By-User" 5 | ) 6 | -------------------------------------------------------------------------------- /v2/pkg/net/webrtc/signallingserver/messages/unmarshal.go: -------------------------------------------------------------------------------- 1 | package messages 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | 7 | "github.com/forestnode-io/oneshot/v2/pkg/net/webrtc/signallingserver/proto" 8 | ) 9 | 10 | var ErrInvalidRequestType = fmt.Errorf("invalid request type") 11 | 12 | func Unmarshal(typeName string, data []byte) (Message, error) { 13 | switch typeName { 14 | case "Handshake": 15 | var h Handshake 16 | err := json.Unmarshal(data, &h) 17 | return &h, err 18 | case "ServerArrivalRequest": 19 | var a ServerArrivalRequest 20 | err := json.Unmarshal(data, &a) 21 | return &a, err 22 | case "ServerArrivalResponse": 23 | var a ServerArrivalResponse 24 | err := json.Unmarshal(data, &a) 25 | return &a, err 26 | case "GetOfferRequest": 27 | var g GetOfferRequest 28 | err := json.Unmarshal(data, &g) 29 | return &g, err 30 | case "GetOfferResponse": 31 | var g GetOfferResponse 32 | err := json.Unmarshal(data, &g) 33 | return &g, err 34 | case "GotAnswerRequest": 35 | var g GotAnswerRequest 36 | err := json.Unmarshal(data, &g) 37 | return &g, err 38 | case "GotAnswerResponse": 39 | var g GotAnswerResponse 40 | err := json.Unmarshal(data, &g) 41 | return &g, err 42 | case "AnswerOfferRequest": 43 | var a AnswerOfferRequest 44 | err := json.Unmarshal(data, &a) 45 | return &a, err 46 | case "AnswerOfferResponse": 47 | var a AnswerOfferResponse 48 | err := json.Unmarshal(data, &a) 49 | return &a, err 50 | case "Ping": 51 | var p Ping 52 | return &p, nil 53 | case "UpdatePingRateRequest": 54 | var u UpdatePingRateRequest 55 | err := json.Unmarshal(data, &u) 56 | return &u, err 57 | case "FinishedSessionRequest": 58 | var s FinishedSessionRequest 59 | err := json.Unmarshal(data, &s) 60 | return &s, err 61 | } 62 | 63 | return nil, fmt.Errorf("unknown message type: %s", typeName) 64 | } 65 | 66 | func FromRPCEnvelope(env *proto.Envelope) (Message, error) { 67 | return Unmarshal(env.Type, env.Data) 68 | } 69 | 70 | func ToRPCEnvelope(msg Message) (*proto.Envelope, error) { 71 | data, err := json.Marshal(msg) 72 | if err != nil { 73 | return nil, err 74 | } 75 | return &proto.Envelope{ 76 | Type: msg.Type(), 77 | Data: data, 78 | }, nil 79 | } 80 | -------------------------------------------------------------------------------- /v2/pkg/net/webrtc/signallingserver/proto/generate.go: -------------------------------------------------------------------------------- 1 | package proto 2 | 3 | //go:generate ./generate.sh 4 | -------------------------------------------------------------------------------- /v2/pkg/net/webrtc/signallingserver/proto/generate.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | SRC_DIR=proto/ 3 | DST_DIR=. 4 | FILES=$(find proto -iname "*.proto") 5 | 6 | protoc \ 7 | -I=$SRC_DIR \ 8 | --proto_path=$SRC_DIR \ 9 | --go_opt=paths=source_relative \ 10 | --go_out=$DST_DIR \ 11 | --go-grpc_opt=paths=source_relative \ 12 | --go-grpc_out=$DST_DIR \ 13 | $FILES 2>&1 > /dev/null -------------------------------------------------------------------------------- /v2/pkg/net/webrtc/signallingserver/proto/proto/signallingServer.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package signallingserverproto; 3 | option go_package = "github.com/forestnode-io/oneshot/v2/pkg/net/webrtc/signallingserver/proto"; 4 | 5 | message Envelope { 6 | string type = 1; 7 | bytes data = 2; 8 | string error = 3; 9 | } 10 | 11 | service SignallingServer { 12 | rpc Connect(stream Envelope) returns (stream Envelope) {} 13 | } -------------------------------------------------------------------------------- /v2/pkg/os/os.go: -------------------------------------------------------------------------------- 1 | package os 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "sort" 7 | ) 8 | 9 | // ReadDirSorted reads the directory named by dirname and returns a sorted list of directory entries. 10 | // If dirsOnly is true, only directories are returned. 11 | // The entries are sorted by modification time, in ascending order. 12 | func ReadDirSorted(name string, dirsOnly bool) ([]os.DirEntry, error) { 13 | entries, err := os.ReadDir(name) 14 | if err != nil { 15 | return nil, fmt.Errorf("failed to read directory: %w", err) 16 | } 17 | 18 | info := make(map[os.DirEntry]os.FileInfo, len(entries)) 19 | for _, entry := range entries { 20 | ei, err := entry.Info() 21 | if err != nil { 22 | return nil, fmt.Errorf("failed to get file info: %w", err) 23 | } 24 | if !ei.IsDir() && dirsOnly { 25 | continue 26 | } 27 | info[entry] = ei 28 | } 29 | 30 | sde := sortableDirEntries{ 31 | entries: entries, 32 | info: info, 33 | } 34 | 35 | sort.Sort(&sde) 36 | 37 | return sde.entries, nil 38 | } 39 | 40 | type sortableDirEntries struct { 41 | entries []os.DirEntry 42 | info map[os.DirEntry]os.FileInfo 43 | } 44 | 45 | func (s *sortableDirEntries) Len() int { 46 | return len(s.entries) 47 | } 48 | 49 | func (s *sortableDirEntries) Less(i, j int) bool { 50 | info_i := s.info[s.entries[i]] 51 | info_j := s.info[s.entries[j]] 52 | 53 | return info_i.ModTime().Before(info_j.ModTime()) 54 | } 55 | 56 | func (s *sortableDirEntries) Swap(i, j int) { 57 | s.entries[i], s.entries[j] = s.entries[j], s.entries[i] 58 | } 59 | -------------------------------------------------------------------------------- /v2/pkg/output/codes/ansi.go: -------------------------------------------------------------------------------- 1 | package codes 2 | 3 | const ( 4 | ANSI_CLEARLINE = "\033[2K\r" 5 | ANSI_HIDECURSOR = "\033[?251" 6 | ANSI_SHOWCURSOR = "\033[?25h" 7 | ) 8 | -------------------------------------------------------------------------------- /v2/pkg/output/fmt/fmt.go: -------------------------------------------------------------------------------- 1 | package fmt 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | func RoundedDurationString(d time.Duration, digits int) string { 9 | dd := 1 10 | for range make([]struct{}, digits) { 11 | dd *= 10 12 | } 13 | ptd := time.Duration(dd) 14 | 15 | switch { 16 | case d > time.Second: 17 | d = d.Round(time.Second / ptd) 18 | case d > time.Millisecond: 19 | d = d.Round(time.Millisecond / ptd) 20 | case d > time.Microsecond: 21 | d = d.Round(time.Microsecond / ptd) 22 | } 23 | return d.String() 24 | } 25 | 26 | const ( 27 | kb = 1000 28 | mb = kb * 1000 29 | gb = mb * 1000 30 | ) 31 | 32 | func PrettySize(n int64) string { 33 | var ( 34 | str string 35 | size = float64(n) 36 | ) 37 | 38 | // Create the size string using appropriate units: B, KB, MB, and GB 39 | switch { 40 | case size < kb: 41 | str = fmt.Sprintf("%dB", n) 42 | case size < mb: 43 | size = size / kb 44 | str = fmt.Sprintf("%.2fKB", size) 45 | case size < gb: 46 | size = size / mb 47 | str = fmt.Sprintf("%.2fMB", size) 48 | default: 49 | size = size / gb 50 | str = fmt.Sprintf("%.2fGB", size) 51 | } 52 | 53 | return str 54 | } 55 | 56 | // PrettyRate returns a pretty version of n, where n is in bytes per nanosecond 57 | func PrettyRate(n float64) string { 58 | var ( 59 | str string 60 | rate = float64(n) 61 | ) 62 | 63 | // Create the size string using appropriate units: B, KB, MB, and GB 64 | switch { 65 | case rate < kb: 66 | str = fmt.Sprintf("%.2fB/s", rate) 67 | case rate < mb: 68 | rate = rate / kb 69 | str = fmt.Sprintf("%.2fKB/s", rate) 70 | case rate < gb: 71 | rate = rate / mb 72 | str = fmt.Sprintf("%.2fMB/s", rate) 73 | default: 74 | rate = rate / gb 75 | str = fmt.Sprintf("%.2fGB/s", rate) 76 | } 77 | 78 | return str 79 | } 80 | 81 | type Number interface { 82 | ~float32 | ~float64 | ~int | ~int32 | ~int64 83 | } 84 | 85 | func PrettyPercent[T Number](x, total T) string { 86 | if total == 0 { 87 | return "n/a" 88 | } 89 | return fmt.Sprintf("%.2f%%", float64(100*x/total)) 90 | } 91 | 92 | func Address(host string, port int) string { 93 | if port != 0 { 94 | return fmt.Sprintf("%s:%d", host, port) 95 | } 96 | 97 | return host 98 | } 99 | -------------------------------------------------------------------------------- /v2/pkg/output/human.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "context" 5 | "errors" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/forestnode-io/oneshot/v2/pkg/events" 10 | "github.com/rs/zerolog" 11 | ) 12 | 13 | func runHuman(ctx context.Context, o *output) { 14 | for event := range o.events { 15 | switch event := event.(type) { 16 | case *events.ClientDisconnected: 17 | o.disconnectedClients = append(o.disconnectedClients, o.currentClientSession) 18 | o.currentClientSession = nil 19 | case *events.File: 20 | o.currentClientSession.File = event 21 | if bf, ok := o.currentClientSession.File.Content.(func() []byte); ok && bf != nil { 22 | if o.cmdName == "reverse-proxy" { 23 | os.Stdout.Write(bf()) 24 | } 25 | } 26 | case *events.HTTPRequest: 27 | o.currentClientSession = &ClientSession{ 28 | Request: event, 29 | } 30 | case events.HTTPRequestBody: 31 | body, err := event() 32 | if err != nil { 33 | panic(err) 34 | } 35 | o.currentClientSession.Request.Body = body 36 | fmt.Fprintln(os.Stdout, string(body)) 37 | default: 38 | } 39 | } 40 | _human_handleContextDone(ctx, o) 41 | } 42 | 43 | func _human_handleContextDone(ctx context.Context, o *output) { 44 | log := zerolog.Ctx(ctx) 45 | if err := events.GetCancellationError(ctx); err != nil { 46 | log.Error().Err(err). 47 | Msg("connection cancelled event") 48 | } 49 | } 50 | 51 | type PrintableError struct { 52 | Err error 53 | } 54 | 55 | func (h *PrintableError) Error() string { 56 | return fmt.Sprintf("%v", h.Err) 57 | } 58 | 59 | func (h *PrintableError) Unwrap() error { 60 | return h.Err 61 | } 62 | 63 | func WrapPrintable(err error) error { 64 | if err == nil { 65 | return nil 66 | } 67 | return &PrintableError{Err: err} 68 | } 69 | 70 | func IsPrintable(err error) bool { 71 | var e *PrintableError 72 | return errors.As(err, &e) 73 | } 74 | -------------------------------------------------------------------------------- /v2/pkg/output/progress.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "fmt" 5 | "os" 6 | "sync/atomic" 7 | "time" 8 | 9 | oneshotfmt "github.com/forestnode-io/oneshot/v2/pkg/output/fmt" 10 | ) 11 | 12 | const progDisplayTimeFormat = "2006-01-02T15:04:05-0700" 13 | 14 | func displayDynamicProgress(o *output, prefix string, start time.Time, prog *atomic.Int64, total int64) time.Time { 15 | var ( 16 | progress = prog.Load() 17 | out = o.dynamicOutput 18 | rate float64 19 | ) 20 | 21 | rate = bytesPerSecond(progress-o.lastProgressDisplayAmount, o.displayProgresssPeriod) 22 | o.lastProgressDisplayAmount = progress 23 | 24 | o.dynamicOutput.resetLine() 25 | fmt.Fprint(out, prefix) 26 | 27 | var ( 28 | duration = time.Since(start) 29 | durationString = oneshotfmt.RoundedDurationString(duration, 2) 30 | sizeString = oneshotfmt.PrettySize(progress) 31 | rateString = oneshotfmt.PrettyRate(rate) 32 | ) 33 | 34 | if total != 0 { 35 | percent := oneshotfmt.PrettyPercent(progress, total) 36 | if 1 <= rate { 37 | deltaBytes := total - progress 38 | timeLeft := deltaBytes / int64(rate) // [B] / ( [B/s] ) = [s] 39 | fmt.Fprintf(out, "\t%v\t%v\t%s\t%s\t%s", sizeString, rateString, percent, durationString, time.Duration(timeLeft)*time.Second) 40 | } else { 41 | fmt.Fprintf(out, "\t%v\t%v\t%s\t%s\tn/a", sizeString, rateString, oneshotfmt.PrettyPercent(progress, total), durationString) 42 | } 43 | } else { 44 | fmt.Fprintf(out, "\t%v\t%v\tn/a\t%s\tn/a", sizeString, rateString, durationString) 45 | } 46 | 47 | out.flush() 48 | 49 | return start 50 | } 51 | 52 | func displayProgressSuccessFlush(o *output, prefix string, start time.Time, total int64) { 53 | duration := time.Since(start) 54 | tail := fmt.Sprintf("\t%s\t%v\t100%%\t%v\tsuccess\n", 55 | oneshotfmt.PrettySize(total), 56 | oneshotfmt.PrettyRate(bytesPerSecond(total, duration)), 57 | oneshotfmt.RoundedDurationString(duration, 2), 58 | ) 59 | _displayFlush(o, prefix+tail, true) 60 | } 61 | 62 | func displayProgressFailFlush(o *output, prefix string, start time.Time, prog, total int64) { 63 | duration := time.Since(start) 64 | tail := fmt.Sprintf("\t%s\t%v\t%s\t%v\tfail\n", 65 | oneshotfmt.PrettySize(prog), 66 | oneshotfmt.PrettyRate(bytesPerSecond(prog, duration)), 67 | oneshotfmt.PrettyPercent(prog, total), 68 | oneshotfmt.RoundedDurationString(duration, 2), 69 | ) 70 | _displayFlush(o, prefix+tail, false) 71 | } 72 | 73 | func _displayFlush(o *output, s string, success bool) { 74 | // if we were dynamically displaying progress to stderr 75 | if o.dynamicOutput != nil { 76 | // update the progress there 77 | o.dynamicOutput.resetLine() 78 | if color := o.stderrFailColor; !success && color != nil { 79 | payload := o.dynamicOutput.String(s) 80 | payload = payload.Foreground(color) 81 | fmt.Fprint(o.dynamicOutput, payload) 82 | } else { 83 | fmt.Fprint(o.dynamicOutput, s) 84 | } 85 | 86 | o.dynamicOutput.flush() 87 | } else { 88 | // otherwise, just print to stderr 89 | if color := o.stderrFailColor; !success && color != nil && o.stderrTTY != nil { 90 | payload := o.stderrTTY.String(s) 91 | payload = payload.Foreground(color) 92 | fmt.Fprint(o.stderrTTY, payload) 93 | } else { 94 | fmt.Fprint(os.Stderr, s) 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /v2/pkg/output/quiet.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/forestnode-io/oneshot/v2/pkg/events" 7 | "github.com/rs/zerolog" 8 | ) 9 | 10 | func runQuiet(ctx context.Context, o *output) { 11 | log := zerolog.Ctx(ctx) 12 | 13 | for { 14 | select { 15 | case <-ctx.Done(): 16 | if err := events.GetCancellationError(ctx); err != nil { 17 | log.Error().Err(err). 18 | Msg("connection cancelled event") 19 | } 20 | return 21 | case event := <-o.events: 22 | switch event := event.(type) { 23 | case *events.File: 24 | if bf, ok := event.Content.(func() []byte); ok && bf != nil { 25 | _ = bf() 26 | } 27 | case events.HTTPRequestBody: 28 | _, _ = event() 29 | } 30 | } 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /v2/pkg/output/responseWriter.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | ) 7 | 8 | type ResponseWriter struct { 9 | W http.ResponseWriter 10 | BufferedW io.Writer 11 | 12 | wroteHeader bool 13 | } 14 | 15 | func (w *ResponseWriter) Header() http.Header { 16 | return w.W.Header() 17 | } 18 | 19 | func (w *ResponseWriter) Write(p []byte) (int, error) { 20 | if !w.wroteHeader { 21 | w.W.WriteHeader(http.StatusOK) 22 | w.wroteHeader = true 23 | } 24 | return w.BufferedW.Write(p) 25 | } 26 | 27 | func (w *ResponseWriter) WriteHeader(statusCode int) { 28 | w.W.WriteHeader(statusCode) 29 | w.wroteHeader = true 30 | } 31 | -------------------------------------------------------------------------------- /v2/pkg/output/setCommandInvocation.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "github.com/spf13/cobra" 5 | ) 6 | 7 | func (o *output) setCommandInvocation(cmd *cobra.Command, args []string) { 8 | var ( 9 | name = cmd.Name() 10 | argc = len(args) 11 | 12 | includeContent = func() { 13 | // if outputting json report and executing a command, include the sent body in the report 14 | // since the user may not have a copy of it laying around 15 | if _, exclude := o.FormatOpts["exclude-file-contents"]; !exclude { 16 | o.FormatOpts["include-file-contents"] = struct{}{} 17 | } 18 | } 19 | ) 20 | 21 | o.gotInvocationInfo = true 22 | o.cmdName = name 23 | cmd.VisitParents(func(c *cobra.Command) { 24 | if c.Name() == "oneshot" { 25 | return 26 | } 27 | o.cmdName = c.Name() + " " + o.cmdName 28 | }) 29 | 30 | switch o.cmdName { 31 | case "exec": 32 | if o.Format == "json" { 33 | includeContent() 34 | } 35 | case "redirect": 36 | case "webrtc client send": 37 | fallthrough 38 | case "send": 39 | switch argc { 40 | case 0: // sending from stdin 41 | if o.Format != "json" { 42 | o.enableDynamicOutput() 43 | } else { 44 | includeContent() 45 | } 46 | default: // sending file(s) 47 | if o.Format != "json" { 48 | o.enableDynamicOutput() 49 | } 50 | } 51 | case "webrtc client receive": 52 | fallthrough 53 | case "receive": 54 | switch argc { 55 | case 0: // receiving to stdout 56 | if o.Format == "json" { 57 | includeContent() 58 | } 59 | default: // receiving to a file 60 | if !o.quiet { 61 | o.enableDynamicOutput() 62 | } 63 | } 64 | case "reverse-proxy": 65 | if o.Format == "json" { 66 | includeContent() 67 | } 68 | case "webrtc signalling-server": 69 | case "webrtc browser-client": 70 | case "version": 71 | default: 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /v2/pkg/output/spinner.go: -------------------------------------------------------------------------------- 1 | package output 2 | 3 | import ( 4 | "context" 5 | "fmt" 6 | "time" 7 | ) 8 | 9 | func DisplaySpinner(ctx context.Context, period time.Duration, prefix, succ string, charSet []string) func() { 10 | o := getOutput(ctx) 11 | if o.quiet || o.Format == "json" { 12 | return func() {} 13 | } 14 | 15 | var ( 16 | out = o.dynamicOutput 17 | done chan struct{} 18 | csLen = len(charSet) 19 | ) 20 | 21 | if out != nil { 22 | done = make(chan struct{}) 23 | ticker := time.NewTicker(period) 24 | 25 | go func() { 26 | out.resetLine() 27 | fmt.Printf("%s %s", prefix, charSet[0]) 28 | out.flush() 29 | 30 | idx := 1 31 | for { 32 | select { 33 | case <-done: 34 | ticker.Stop() 35 | return 36 | case <-ticker.C: 37 | dyn := charSet[idx%csLen] 38 | idx++ 39 | 40 | out.resetLine() 41 | fmt.Fprintf(out, "%s %s", prefix, dyn) 42 | out.flush() 43 | } 44 | } 45 | }() 46 | } 47 | 48 | return func() { 49 | if done != nil { 50 | done <- struct{}{} 51 | close(done) 52 | done = nil 53 | } 54 | 55 | out.resetLine() 56 | fmt.Fprintln(out, succ) 57 | out.flush() 58 | } 59 | } 60 | -------------------------------------------------------------------------------- /v2/pkg/sys/sys.go: -------------------------------------------------------------------------------- 1 | package sys 2 | 3 | import "runtime" 4 | 5 | // copied from https://github.com/golang/go/blob/ebb572d82f97d19d0016a49956eb1fddc658eb76/src/go/build/syslist.go#L38 6 | var unixOS = map[string]struct{}{ 7 | "aix": {}, 8 | "android": {}, 9 | "darwin": {}, 10 | "dragonfly": {}, 11 | "freebsd": {}, 12 | "hurd": {}, 13 | "illumos": {}, 14 | "ios": {}, 15 | "linux": {}, 16 | "netbsd": {}, 17 | "openbsd": {}, 18 | "solaris": {}, 19 | } 20 | 21 | func RunningOnUNIX() bool { 22 | _, isUnix := unixOS[runtime.GOOS] 23 | return isUnix 24 | } 25 | -------------------------------------------------------------------------------- /v2/pkg/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import "os" 4 | 5 | var ( 6 | Version string 7 | APIVersion string 8 | Credit string 9 | License = "Apache License 2.0" 10 | ) 11 | 12 | func init() { 13 | if os.Getenv("ONESHOT_SKIP_INIT_CHECKS") != "" { 14 | return 15 | } 16 | 17 | if Version == "" { 18 | panic("Version not set") 19 | } 20 | if APIVersion == "" { 21 | panic("APIVersion not set") 22 | } 23 | if License == "" { 24 | panic("License not set") 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /v2/version.txt: -------------------------------------------------------------------------------- 1 | v2.1.1 2 | -------------------------------------------------------------------------------- /version.txt: -------------------------------------------------------------------------------- 1 | v1.5.1 2 | --------------------------------------------------------------------------------