├── .dockerignore ├── .github ├── FUNDING.yml └── workflows │ ├── go.yml │ └── release.yml ├── .gitignore ├── CHANGELOG.md ├── Dockerfile ├── LICENSE ├── README.md ├── examples ├── bash │ └── bash_in_docker │ │ ├── client.sh │ │ ├── logic.sh │ │ └── run_docker.sh └── python-function │ ├── README.md │ ├── input.json │ ├── input.py │ ├── main.py │ ├── pathfind.py │ └── requirements.txt ├── go.mod ├── go.sum ├── lib ├── flag.go ├── server.go └── server_test.go └── main.go /.dockerignore: -------------------------------------------------------------------------------- 1 | .git -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: beefsack 2 | -------------------------------------------------------------------------------- /.github/workflows/go.yml: -------------------------------------------------------------------------------- 1 | name: build 2 | on: [push, pull_request] 3 | jobs: 4 | build: 5 | name: Build 6 | strategy: 7 | matrix: 8 | go: ['1.14', '1.15'] 9 | os: [ubuntu-latest, macos-latest, windows-latest] 10 | runs-on: ${{ matrix.os }} 11 | steps: 12 | - name: Set up Go ${{ matrix.go }} 13 | uses: actions/setup-go@v2 14 | with: 15 | go-version: ${{ matrix.go }} 16 | - name: Check out code into the Go module directory 17 | uses: actions/checkout@v2 18 | - name: Get dependencies 19 | run: go get -v -t -d ./... 20 | - name: Build 21 | run: go build -v ./... 22 | - name: Test 23 | run: go test -v ./... -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | on: 2 | release: 3 | types: [created] 4 | 5 | jobs: 6 | releases-matrix: 7 | name: Release Go Binary 8 | runs-on: ubuntu-latest 9 | strategy: 10 | matrix: 11 | # build and publish in parallel: linux/386, linux/amd64, windows/386, windows/amd64, darwin/386, darwin/amd64 12 | goos: [linux, windows, darwin] 13 | goarch: ["386", amd64] 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: wangyoucao577/go-release-action@master 17 | with: 18 | github_token: ${{ secrets.GITHUB_TOKEN }} 19 | goos: ${{ matrix.goos }} 20 | goarch: ${{ matrix.goarch }} 21 | extra_files: LICENSE README.md 22 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .vscode 2 | __pycache__ -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | All notable changes to this project will be documented in this file. 3 | 4 | The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), 5 | and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). 6 | 7 | ## [Unreleased] 8 | 9 | ## [1.5.0] - 2020-08-29 10 | ### Changed 11 | - Ignore client connection close errors (specifically ignoring syscall.EPIPE) 12 | 13 | ## [1.4.0] - 2020-08-29 14 | ## Added 15 | - Include LICENSE and README.md in build archives. 16 | 17 | ## [1.3.0] - 2020-08-27 18 | ### Changed 19 | - Renamed project to webify. 20 | 21 | ## [1.2.0] - 2020-08-27 22 | ### Added 23 | - Started CHANGELOG.md. 24 | - Proper help with -h. 25 | - Options and arguments can be passed by environment variables. 26 | - Go module files for third party deps. 27 | 28 | ### Removed 29 | - Docker script to pass environment variable as script, as it is now supported 30 | directly. -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.14 2 | 3 | WORKDIR /go/src/webify 4 | 5 | COPY . . 6 | RUN go get && go build 7 | 8 | # By default, webify listens on all interfaces on port 8080 9 | EXPOSE 80 10 | ENV ADDR :80 11 | 12 | # By default, webify executes /script 13 | ENV SCRIPT /script 14 | 15 | CMD ["./webify"] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2020 Michael Alexander 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 4 | 5 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 6 | 7 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 8 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |

webify

3 |

Turn functions and commands into web services

4 |

5 | Build Status 6 | Go Report Card 7 | MIT License 8 |

9 |

10 | 11 | For a real world example, see [turning a Python function into a web 12 | service](examples/python-function). 13 | 14 | ## Overview 15 | 16 | `webify` is a very basic CGI server which forwards all requests to a single 17 | script. A design goal is to be as zero-config as possible. 18 | 19 | `webify` invokes your script and writes the request body to your process' 20 | stdin. Stdout is then passed back to the client as the HTTP response body. 21 | 22 | If your script returns a non-zero exit code, the HTTP response status code will 23 | be 500. 24 | 25 | ## Installation 26 | 27 | `webify` is available from the [project's releases page](https://github.com/beefsack/webify/releases). 28 | 29 | On macOS, it can also be installed via [MacPorts](https://ports.macports.org/port/py-boltons/summary): 30 | 31 | ```bash 32 | sudo port install webify 33 | ``` 34 | 35 | ## Usage 36 | 37 | ```bash 38 | # Make a web service out of `wc` to count the characters in the request body. 39 | $ webify wc -c 40 | 2020/08/25 12:42:32 listening on :8080, proxying to wc -c 41 | 42 | ... 43 | 44 | $ curl -d 'This is a really long sentence' http://localhost:8080 45 | 30 46 | ``` 47 | 48 | ### Official Docker image 49 | 50 | The official Docker image is [beefsack/webify](https://hub.docker.com/r/beefsack/webify). 51 | 52 | It can be configured using the following environment variables: 53 | 54 | * `ADDR` - the address to listen on inside the container, defaults to `:80` 55 | * `SCRIPT` - the command to execute, defaults to `/script` 56 | 57 | #### Mounting script and running official image 58 | 59 | ``` 60 | $ docker run -it --rm -p 8080:80 -v /path/to/my/script:/script beefsack/webify:latest 61 | 2020/08/25 04:27:46 listening on :80, proxying to /script 62 | 63 | ... 64 | 65 | $ curl -d 'Some data' http://localhost:8080 66 | ``` 67 | 68 | #### Building a new image using official image as base 69 | 70 | Create a `Dockerfile` like the following: 71 | 72 | ``` 73 | FROM beefsack/webify:latest 74 | COPY myscript /script 75 | ``` 76 | 77 | ## Contributing 78 | Pull requests are welcome. For major changes, please open an issue first to discuss what you would like to change. 79 | 80 | Please make sure to update tests as appropriate. 81 | 82 | ## License 83 | [MIT](https://choosealicense.com/licenses/mit/) 84 | -------------------------------------------------------------------------------- /examples/bash/bash_in_docker/client.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | curl -d 'Hello World!' http://localhost:8080 4 | -------------------------------------------------------------------------------- /examples/bash/bash_in_docker/logic.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -x #optional logging 4 | cat /dev/stdin 5 | -------------------------------------------------------------------------------- /examples/bash/bash_in_docker/run_docker.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | docker run -it --rm -p 8080:80 -v $(realpath logic.sh):/script beefsack/webify:latest 4 | -------------------------------------------------------------------------------- /examples/python-function/README.md: -------------------------------------------------------------------------------- 1 | # Example Python function 2 | 3 | This example shows how we can turn a Python function into a web service using 4 | `webify`. 5 | 6 | Our example here will provide an A* pathfinding service. A client will send a 7 | world, a start location, and an end location. The service will execute our 8 | pathfinding function and return the result. 9 | 10 | ## Pathfinding function 11 | 12 | Our base pathfinding function is in [`pathfind.py`](pathfind.py): 13 | 14 | ```python 15 | def pathfind(world, start_x, start_y, end_x, end_y): 16 | """Find a path from start to end in a world""" 17 | # ... 18 | ``` 19 | 20 | This function returns a dictionary containing the path, the number of runs, and 21 | a rendered result. 22 | 23 | ## Connect function to stdin and stdout 24 | 25 | Now we need a main function that takes stdin as input, and returns output to 26 | stdout. We will use JSON as the format for both input and output. 27 | 28 | Our main function is in [`main.py`](main.py): 29 | 30 | ```python 31 | def main(): 32 | # Read input from stdin 33 | input = json.load(sys.stdin) 34 | 35 | # Validate our input against our JSON schema 36 | validate(instance=input, schema=SCHEMA) 37 | 38 | # Find a path from start to end in our world 39 | result = pathfind(**input) 40 | 41 | # Print result to stdout 42 | json.dump(result, sys.stdout) 43 | ``` 44 | 45 | It is **very important** to validate input, particularly on a service exposed 46 | on a network. We do this using a JSON schema which is defined in 47 | [`input.py`](input.py). 48 | 49 | ## Running in shell 50 | 51 | We can test our python script directly at the shell using the example 52 | [`input.json`](input.json) file: 53 | 54 | ```bash 55 | $ # First we need to install the dependencies 56 | $ pip install -r requirements.txt 57 | ... 58 | Successfully installed 59 | 60 | $ # Get the raw JSON result 61 | $ python main.py < input.json 62 | {"path": [[0, 0], [1, 0], [2, 0], [2, 1], [2, 2], [2, 3], [3, 3]], "runs": 9, "render": "+----+\n|sxx#|\n| #x |\n|##x#|\n| xe|\n+----+"} 63 | 64 | $ # Just show the rendered world from the result 65 | $ python main.py < input.json | jq -r .render 66 | +----+ 67 | |sxx#| 68 | | #x | 69 | |##x#| 70 | | xe| 71 | +----+ 72 | ``` 73 | 74 | ## Use `webify` to turn it into a service 75 | 76 | Now all we need to do is run `webify` to expose our function to the network over 77 | HTTP: 78 | 79 | ```bash 80 | $ webify python main.py 81 | 2020/09/09 13:47:34 listening on :8080, proxying to python main.py 82 | ... 83 | 84 | $ # Get the raw JSON result 85 | $ curl -d @input.json http://localhost:8080 86 | {"path": [[0, 0], [1, 0], [2, 0], [2, 1], [2, 2], [2, 3], [3, 3]], "runs": 9, "render": "+----+\n|sxx#|\n| #x |\n|##x#|\n| xe|\n+----+"} 87 | 88 | $ # Just show the rendered world from the result 89 | $ curl -d @input.json http://localhost:8080 | jq -r .render 90 | +----+ 91 | |sxx#| 92 | | #x | 93 | |##x#| 94 | | xe| 95 | +----+ 96 | ``` -------------------------------------------------------------------------------- /examples/python-function/input.json: -------------------------------------------------------------------------------- 1 | { 2 | "start_x": 0, 3 | "start_y": 0, 4 | "end_x": 3, 5 | "end_y": 3, 6 | "world": [ 7 | [1, 1, 1, 0], 8 | [1, 0, 1, 1], 9 | [0, 0, 1, 0], 10 | [1, 1, 1, 1] 11 | ] 12 | } -------------------------------------------------------------------------------- /examples/python-function/input.py: -------------------------------------------------------------------------------- 1 | # Schema to validate input against 2 | SCHEMA = { 3 | 'type': 'object', 4 | 'properties': { 5 | 'start_x': {'type': 'number'}, 6 | 'start_y': {'type': 'number'}, 7 | 'end_x': {'type': 'number'}, 8 | 'end_y': {'type': 'number'}, 9 | 'world': { 10 | 'type': 'array', 11 | 'items': { 12 | 'type': 'array', 13 | 'items': {'enum': [0, 1]}, 14 | 'minItems': 1, 15 | }, 16 | 'minItems': 1, 17 | }, 18 | }, 19 | 'required': [ 20 | 'start_x', 21 | 'start_y', 22 | 'end_x', 23 | 'end_y', 24 | 'world', 25 | ], 26 | } 27 | -------------------------------------------------------------------------------- /examples/python-function/main.py: -------------------------------------------------------------------------------- 1 | from jsonschema import validate 2 | 3 | import json 4 | import sys 5 | 6 | # We have a JSON schema to validate our input 7 | from input import SCHEMA 8 | # We have an A* pathfinder defined in pathfind.py 9 | from pathfind import pathfind 10 | 11 | 12 | def main(): 13 | # Read input from stdin 14 | input = json.load(sys.stdin) 15 | 16 | # Validate our input against our JSON schema 17 | validate(instance=input, schema=SCHEMA) 18 | 19 | # Find a path from start to end in our world 20 | result = pathfind(**input) 21 | 22 | # Print result to stdout 23 | json.dump(result, sys.stdout) 24 | 25 | 26 | if __name__ == '__main__': 27 | main() 28 | -------------------------------------------------------------------------------- /examples/python-function/pathfind.py: -------------------------------------------------------------------------------- 1 | from pathfinding.core.grid import Grid 2 | from pathfinding.finder.a_star import AStarFinder 3 | 4 | 5 | def pathfind(world, start_x, start_y, end_x, end_y): 6 | """Find a path from start to end in a world""" 7 | grid = Grid(matrix=world) 8 | start = grid.node(start_x, start_y) 9 | end = grid.node(end_x, end_y) 10 | 11 | finder = AStarFinder() 12 | path, runs = finder.find_path(start, end, grid) 13 | 14 | return { 15 | 'path': path, 16 | 'runs': runs, 17 | 'render': grid.grid_str(path=path, start=start, end=end) 18 | } 19 | -------------------------------------------------------------------------------- /examples/python-function/requirements.txt: -------------------------------------------------------------------------------- 1 | jsonschema==3.2.0 2 | pathfinding==0.0.4 -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/beefsack/webify 2 | 3 | go 1.15 4 | 5 | require ( 6 | github.com/mattn/go-shellwords v1.0.10 7 | github.com/namsral/flag v1.7.4-pre 8 | ) 9 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/mattn/go-shellwords v1.0.10 h1:Y7Xqm8piKOO3v10Thp7Z36h4FYFjt5xB//6XvOrs2Gw= 2 | github.com/mattn/go-shellwords v1.0.10/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= 3 | github.com/namsral/flag v1.7.4-pre h1:b2ScHhoCUkbsq0d2C15Mv+VU8bl8hAXV8arnWiOHNZs= 4 | github.com/namsral/flag v1.7.4-pre/go.mod h1:OXldTctbM6SWH1K899kPZcf65KxJiD7MsceFUpB5yDo= 5 | -------------------------------------------------------------------------------- /lib/flag.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "fmt" 5 | "log" 6 | "os" 7 | "strings" 8 | 9 | shellwords "github.com/mattn/go-shellwords" 10 | "github.com/namsral/flag" 11 | ) 12 | 13 | // EnvScript is the name of the environment variable to configure the script to 14 | // execute. 15 | const EnvScript = "SCRIPT" 16 | 17 | const helpHeader = ` 18 | webify is a simple helper to turn a command line script into an HTTP 19 | service. 20 | 21 | Homepage: http://github.com/beefsack/webify 22 | 23 | webify functions by starting the script for each request and piping the 24 | HTTP request body into stdin of the subprocess. stdout is captured and returned 25 | as the body of the HTTP response. 26 | 27 | stderr is not sent to the client, but is logged to the webify process 28 | stderr. stderr can be sent to the client using redirection if required. 29 | 30 | The exit status of the script determines the HTTP status code for the response: 31 | 200 when the exit status is 0, otherwise 500. Because of this, the response 32 | isn't sent until the script completes. 33 | 34 | Example server that responds with the number of lines in the request body: 35 | 36 | webify wc -l 37 | 38 | Piping and redirection are supported by calling a shell directly: 39 | 40 | webify bash -c 'date && wc -l' 41 | 42 | All options are also exposed as environment variables, entirely in uppercase. 43 | Eg. -addr can also be specified using the environment variable ADDR. The script 44 | arguments can be passed in the SCRIPT environment variable instead. 45 | 46 | Available options: 47 | ` 48 | 49 | // Opts are the options parsed from the command line and environment variable. 50 | type Opts struct { 51 | Script []string 52 | Addr string 53 | } 54 | 55 | func init() { 56 | flag.Usage = func() { 57 | fmt.Fprintf(os.Stderr, strings.TrimLeft(helpHeader, "\n")) 58 | flag.PrintDefaults() 59 | } 60 | } 61 | 62 | // ParseConfig parses options from arguments and environment variables. 63 | func ParseConfig() Opts { 64 | opts := Opts{} 65 | 66 | flag.StringVar(&opts.Addr, "addr", ":8080", "the TCP network address to listen on, eg. ':80'") 67 | 68 | flag.Parse() 69 | 70 | opts.Script = flag.Args() 71 | if len(opts.Script) == 0 { 72 | envScript := os.Getenv(EnvScript) 73 | if envScript != "" { 74 | args, err := shellwords.Parse(envScript) 75 | if err != nil { 76 | log.Fatalf("error parsing SCRIPT environment variable: %v", err) 77 | } 78 | opts.Script = args 79 | } else { 80 | // No script was passed via args or env, print usage and exit. 81 | flag.Usage() 82 | os.Exit(2) 83 | } 84 | } 85 | 86 | return opts 87 | } 88 | -------------------------------------------------------------------------------- /lib/server.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "io" 5 | "io/ioutil" 6 | "log" 7 | "net/http" 8 | "os/exec" 9 | "sync" 10 | "syscall" 11 | ) 12 | 13 | // Server is a simple proxy server to pipe HTTP requests to a subprocess' stdin 14 | // and the subprocess' stdout to the HTTP response. 15 | type Server struct { 16 | Opts 17 | } 18 | 19 | func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { 20 | // Start subprocess 21 | cmd := exec.Command(s.Script[0], s.Script[1:]...) 22 | 23 | // Get handles to subprocess stdin, stdout and stderr 24 | stdinPipe, err := cmd.StdinPipe() 25 | if err != nil { 26 | log.Printf("error accessing subprocess stdin: %v", err) 27 | respError(w) 28 | return 29 | } 30 | defer stdinPipe.Close() 31 | stderrPipe, err := cmd.StderrPipe() 32 | if err != nil { 33 | log.Printf("error accessing subprocess stderr: %v", err) 34 | respError(w) 35 | return 36 | } 37 | stdoutPipe, err := cmd.StdoutPipe() 38 | if err != nil { 39 | log.Printf("error accessing subprocess stdout: %v", err) 40 | respError(w) 41 | return 42 | } 43 | 44 | // Start the subprocess 45 | err = cmd.Start() 46 | if err != nil { 47 | log.Printf("error starting subprocess: %v", err) 48 | respError(w) 49 | return 50 | } 51 | 52 | // We use a WaitGroup to wait for all goroutines to finish 53 | wg := sync.WaitGroup{} 54 | 55 | // Write request body to subprocess stdin 56 | wg.Add(1) 57 | go func() { 58 | defer func() { 59 | stdinPipe.Close() 60 | wg.Done() 61 | }() 62 | _, err = io.Copy(stdinPipe, r.Body) 63 | if err != nil { 64 | log.Printf("error writing request body to subprocess stdin: %v", err) 65 | respError(w) 66 | return 67 | } 68 | }() 69 | 70 | // Read all stderr and write to parent stderr if not empty 71 | wg.Add(1) 72 | go func() { 73 | defer wg.Done() 74 | stderr, err := ioutil.ReadAll(stderrPipe) 75 | if err != nil { 76 | log.Printf("error reading subprocess stderr: %v", err) 77 | respError(w) 78 | return 79 | } 80 | if len(stderr) > 0 { 81 | log.Print(string(stderr)) 82 | } 83 | }() 84 | 85 | // Read all stdout, but don't write to the response as we need the exit 86 | // status of the subcommand to know our HTTP response code 87 | wg.Add(1) 88 | var stdout []byte 89 | go func() { 90 | defer wg.Done() 91 | so, err := ioutil.ReadAll(stdoutPipe) 92 | stdout = so 93 | if err != nil { 94 | log.Printf("error reading subprocess stdout: %v", err) 95 | respError(w) 96 | return 97 | } 98 | }() 99 | 100 | // We must consume stdout and stderr before `cmd.Wait()` as per 101 | // doc and example at https://golang.org/pkg/os/exec/#Cmd.StdoutPipe 102 | wg.Wait() 103 | 104 | // Wait for the subprocess to complete 105 | cmdErr := cmd.Wait() 106 | if cmdErr != nil { 107 | // We don't return here because we also want to try to write stdout if 108 | // there was some output 109 | log.Printf("error running subprocess: %v", err) 110 | respError(w) 111 | } 112 | 113 | // Write stdout as the response body 114 | _, err = w.Write(stdout) 115 | // We ignore connection close errors, which appears as `syscall.EPIPE` 116 | if err != nil && err != syscall.EPIPE { 117 | log.Printf("error writing response body: %v", err) 118 | } 119 | } 120 | 121 | /// respError sends an error response back to the client. Currently this is just 122 | /// a 500 status code. 123 | func respError(w http.ResponseWriter) { 124 | w.WriteHeader(http.StatusInternalServerError) 125 | } 126 | -------------------------------------------------------------------------------- /lib/server_test.go: -------------------------------------------------------------------------------- 1 | package lib 2 | 3 | import ( 4 | "bytes" 5 | "io/ioutil" 6 | "net/http/httptest" 7 | "testing" 8 | ) 9 | 10 | func TestServer_Cat(t *testing.T) { 11 | server := Server{ 12 | Opts: Opts{ 13 | Script: []string{"cat"}, 14 | Addr: "http://127.0.0.1", 15 | }, 16 | } 17 | 18 | reqBody := []byte("blah") 19 | 20 | req := httptest.NewRequest("GET", "http://127.0.0.1/", bytes.NewBuffer(reqBody)) 21 | w := httptest.NewRecorder() 22 | server.ServeHTTP(w, req) 23 | 24 | resp := w.Result() 25 | body, err := ioutil.ReadAll(resp.Body) 26 | 27 | if err != nil { 28 | t.Fatalf("error reading response body: %v", err) 29 | } 30 | 31 | if string(body) != string(reqBody) { 32 | t.Errorf("Expected body to be \"%s\" but got \"%s\"", reqBody, body) 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "github.com/beefsack/webify/lib" 5 | 6 | "log" 7 | "net/http" 8 | "strings" 9 | ) 10 | 11 | func main() { 12 | opts := lib.ParseConfig() 13 | 14 | log.Printf("listening on %s, proxying to %s", opts.Addr, strings.Join(opts.Script, " ")) 15 | log.Fatal(http.ListenAndServe(opts.Addr, &lib.Server{Opts: opts})) 16 | } 17 | --------------------------------------------------------------------------------