├── .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" }}
8 | {{ end }}
9 |
10 | {{ define "text-section" }}
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 |
--------------------------------------------------------------------------------