├── .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 |
6 |
7 |
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 |
--------------------------------------------------------------------------------