├── .gitignore ├── .goreleaser.yaml ├── Dockerfile ├── go.mod ├── LICENSE ├── server ├── middleware.go ├── jsonrpc │ └── server.go └── server.go ├── client └── client.go ├── go.sum ├── main.go └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | dist/ 2 | -------------------------------------------------------------------------------- /.goreleaser.yaml: -------------------------------------------------------------------------------- 1 | before: 2 | hooks: 3 | - go mod tidy 4 | 5 | gomod: 6 | proxy: true 7 | 8 | builds: 9 | - env: 10 | - CGO_ENABLED=0 11 | goos: 12 | - linux 13 | goarch: 14 | - amd64 15 | - arm64 16 | flags: 17 | - -trimpath 18 | ldflags: 19 | - -s -w -X main.Version={{.Version}} 20 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:1.19 AS builder 2 | 3 | WORKDIR /go/src/github.com/jamescun/wg-api 4 | COPY . /go/src/github.com/jamescun/wg-api 5 | 6 | RUN CGO_ENABLED=0 GOOS=linux go build -o wg-api main.go 7 | 8 | 9 | FROM scratch 10 | COPY --from=builder /go/src/github.com/jamescun/wg-api/wg-api /wg-api 11 | ENTRYPOINT ["/wg-api"] 12 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jamescun/wg-api 2 | 3 | go 1.17 4 | 5 | require ( 6 | github.com/spf13/pflag v1.0.5 7 | golang.zx2c4.com/wireguard/wgctrl v0.0.0-20220916014741-473347a5e6e3 8 | ) 9 | 10 | require ( 11 | github.com/google/go-cmp v0.5.7 // indirect 12 | github.com/josharian/native v1.0.0 // indirect 13 | github.com/mdlayher/genetlink v1.2.0 // indirect 14 | github.com/mdlayher/netlink v1.6.0 // indirect 15 | github.com/mdlayher/socket v0.2.3 // indirect 16 | golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 // indirect 17 | golang.org/x/net v0.0.0-20220418201149-a630d4f3e7a2 // indirect 18 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c // indirect 19 | golang.org/x/sys v0.0.0-20220412211240-33da011f77ad // indirect 20 | golang.zx2c4.com/wireguard v0.0.0-20220407013110-ef5c587f782d // indirect 21 | ) 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2020 James Cunningham 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is 8 | furnished to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 19 | SOFTWARE. 20 | -------------------------------------------------------------------------------- /server/middleware.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "log" 5 | "net/http" 6 | "strings" 7 | "time" 8 | 9 | "github.com/jamescun/wg-api/server/jsonrpc" 10 | ) 11 | 12 | // PreventReferer blocks any request that contains a Referer or Origin header, 13 | // as this would indicate a web browser is submitting the request and this 14 | // server should NOT be directly accessible that way. 15 | func PreventReferer(next http.Handler) http.Handler { 16 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | if headersExist(r.Header, "Referer", "Origin") { 18 | http.Error(w, "forbidden", http.StatusForbidden) 19 | return 20 | } 21 | 22 | next.ServeHTTP(w, r) 23 | }) 24 | } 25 | 26 | func headersExist(h http.Header, keys ...string) bool { 27 | for _, key := range keys { 28 | if _, ok := h[key]; ok { 29 | return true 30 | } 31 | } 32 | 33 | return false 34 | } 35 | 36 | // Logger logs JSON-RPC requests. 37 | func Logger(next jsonrpc.Handler) jsonrpc.Handler { 38 | return jsonrpc.HandlerFunc(func(w jsonrpc.ResponseWriter, r *jsonrpc.Request) { 39 | t1 := time.Now() 40 | next.ServeJSONRPC(w, r) 41 | t2 := time.Now() 42 | 43 | log.Printf("info: request: method=%q remote_addr=%s duration=%s\n", r.Method, r.RemoteAddr(), t2.Sub(t1)) 44 | }) 45 | } 46 | 47 | // AuthTokens only allows a request to continue if one of the pre-configured 48 | // tokens is provided by the client in the Authorization header, otherwise 49 | // a HTTP 403 Forbidden is returned and the request terminated. 50 | func AuthTokens(tokens ...string) func(http.Handler) http.Handler { 51 | return func(next http.Handler) http.Handler { 52 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 53 | token := strings.TrimSpace(strings.TrimPrefix(r.Header.Get("Authorization"), "Token ")) 54 | 55 | if !stringInSlice(token, tokens) { 56 | http.Error(w, "forbidden", http.StatusForbidden) 57 | return 58 | } 59 | 60 | next.ServeHTTP(w, r) 61 | }) 62 | } 63 | } 64 | 65 | func stringInSlice(s string, vv []string) bool { 66 | for _, v := range vv { 67 | if v == s { 68 | return true 69 | } 70 | } 71 | 72 | return false 73 | } 74 | -------------------------------------------------------------------------------- /client/client.go: -------------------------------------------------------------------------------- 1 | package client 2 | 3 | import ( 4 | "context" 5 | "time" 6 | ) 7 | 8 | // Client is the interface expected to be presented to consumers of the API. 9 | type Client interface { 10 | // GetDeviceInfo returns information such as the public key and type of 11 | // interface for the currently configured device. 12 | GetDeviceInfo(context.Context, *GetDeviceInfoRequest) (*GetDeviceInfoResponse, error) 13 | 14 | // ListPeers retrieves information about all Peers known to the current 15 | // WireGuard interface, including allowed IP addresses and usage stats, 16 | // optionally with pagination. 17 | ListPeers(context.Context, *ListPeersRequest) (*ListPeersResponse, error) 18 | 19 | // GetPeer retrieves a specific Peer by their public key. 20 | GetPeer(context.Context, *GetPeerRequest) (*GetPeerResponse, error) 21 | 22 | // AddPeer inserts a new Peer into the WireGuard interfaces table, multiple 23 | // calls to AddPeer can be used to update details of the Peer. 24 | AddPeer(context.Context, *AddPeerRequest) (*AddPeerResponse, error) 25 | 26 | // RemovePeer deletes a Peer from the WireGuard interfaces table by their 27 | // public key, 28 | RemovePeer(context.Context, *RemovePeerRequest) (*RemovePeerResponse, error) 29 | } 30 | 31 | type Device struct { 32 | Name string `json:"name"` 33 | Type string `json:"type"` 34 | PublicKey string `json:"public_key"` 35 | ListenPort int `json:"listen_port"` 36 | FirewallMark int `json:"firewall_mark,omitempty"` 37 | NumPeers int `json:"num_peers"` 38 | } 39 | 40 | type GetDeviceInfoRequest struct{} 41 | 42 | type GetDeviceInfoResponse struct { 43 | Device *Device `json:"device"` 44 | } 45 | 46 | type Peer struct { 47 | PublicKey string `json:"public_key"` 48 | HasPresharedKey bool `json:"has_preshared_key"` 49 | Endpoint string `json:"endpoint"` 50 | PersistentKeepAlive string `json:"persistent_keep_alive,omitempty"` 51 | LastHandshake time.Time `json:"last_handshake"` 52 | ReceiveBytes int64 `json:"receive_bytes"` 53 | TransmitBytes int64 `json:"transmit_bytes"` 54 | AllowedIPs []string `json:"allowed_ips"` 55 | ProtocolVersion int `json:"protocol_version"` 56 | } 57 | 58 | type ListPeersRequest struct { 59 | Limit int `json:"limit,omitempty"` 60 | Offset int `json:"offset,omitempty"` 61 | } 62 | 63 | type ListPeersResponse struct { 64 | Peers []*Peer `json:"peers"` 65 | } 66 | 67 | type GetPeerRequest struct { 68 | PublicKey string `json:"public_key"` 69 | } 70 | 71 | type GetPeerResponse struct { 72 | Peer *Peer `json:"peer"` 73 | } 74 | 75 | type AddPeerRequest struct { 76 | PublicKey string `json:"public_key"` 77 | PresharedKey string `json:"preshared_key,omitempty"` 78 | Endpoint string `json:"endpoint,omitempty"` 79 | PersistentKeepAlive string `json:"persistent_keep_alive,omitempty"` 80 | AllowedIPs []string `json:"allowed_ips,omitempty"` 81 | 82 | // ValidateOnly ensures only validation is completed, no side effects 83 | ValidateOnly bool `json:"validate_only"` 84 | } 85 | 86 | type AddPeerResponse struct { 87 | // OK will only ever be false if ValidateOnly has been requested. 88 | OK bool `json:"ok"` 89 | } 90 | 91 | type RemovePeerRequest struct { 92 | PublicKey string `json:"public_key"` 93 | 94 | // ValidateOnly ensures only validation is completed, no side effects 95 | ValidateOnly bool `json:"validate_only"` 96 | } 97 | 98 | type RemovePeerResponse struct { 99 | // OK will only ever be false if ValidateOnly has been requested. 100 | OK bool `json:"ok"` 101 | } 102 | -------------------------------------------------------------------------------- /server/jsonrpc/server.go: -------------------------------------------------------------------------------- 1 | package jsonrpc 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net/http" 8 | "strings" 9 | ) 10 | 11 | // Handler responds to JSON-RPC requests. 12 | type Handler interface { 13 | ServeJSONRPC(w ResponseWriter, r *Request) 14 | } 15 | 16 | // HandlerFunc adapts a function into a handler. 17 | type HandlerFunc func(ResponseWriter, *Request) 18 | 19 | // ServeJSONRPC responds to JSON-RPC requests with hf(w, r). 20 | func (hf HandlerFunc) ServeJSONRPC(w ResponseWriter, r *Request) { 21 | hf(w, r) 22 | } 23 | 24 | // Request contains the JSON-RPC paramaters submitted by the client. 25 | type Request struct { 26 | Version string `json:"jsonrpc"` 27 | Method string `json:"method"` 28 | Params json.RawMessage `json:"params,omitempty"` 29 | ID json.RawMessage `json:"id"` 30 | 31 | ctx context.Context 32 | raddr string 33 | } 34 | 35 | // Context returns the execution context of the request, or the background 36 | // context if one is not set. 37 | func (r *Request) Context() context.Context { 38 | if r.ctx == nil { 39 | return context.Background() 40 | } 41 | 42 | return r.ctx 43 | } 44 | 45 | // RemoteAddr returns the remote ip:port of the client. 46 | func (r *Request) RemoteAddr() string { 47 | return r.raddr 48 | } 49 | 50 | // ResponseWriter marshals the JSON-RPC response to the client. 51 | type ResponseWriter interface { 52 | // Write marshals anything given to it as the Result of the JSON-RPC 53 | // interaction. If the given argument implements then Error interface, 54 | // it will be marshalled as the Error. 55 | Write(interface{}) error 56 | } 57 | 58 | type response struct { 59 | Version string `json:"jsonrpc"` 60 | Result interface{} `json:"result,omitempty"` 61 | Error *Error `json:"error,omitempty"` 62 | ID json.RawMessage `json:"id"` 63 | } 64 | 65 | func (r *response) Write(res interface{}) error { 66 | if r.Result != nil && r.Error != nil { 67 | return fmt.Errorf("response already written") 68 | } 69 | 70 | if rpcErr, ok := res.(*Error); ok { 71 | r.Error = rpcErr 72 | } else if err, ok := res.(error); ok { 73 | r.Error = InternalError(err.Error(), nil) 74 | } else { 75 | r.Result = res 76 | } 77 | 78 | return nil 79 | } 80 | 81 | // ContentType is the MIME Type expected of clients and returned by the server. 82 | const ContentType = "application/json" 83 | 84 | // HTTP adapts a JSON-RPC Handler to a HTTP Handler for use in 85 | // HTTP(S) exchanges. 86 | func HTTP(hf Handler) http.Handler { 87 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 88 | if r.Method != http.MethodPost { 89 | http.Error(w, "method not allowed", http.StatusMethodNotAllowed) 90 | return 91 | } 92 | 93 | if hdr := r.Header.Get("Content-Type"); !strings.HasPrefix(hdr, ContentType) { 94 | http.Error(w, fmt.Sprintf("unknown content type %q", hdr), http.StatusBadRequest) 95 | return 96 | } 97 | 98 | req := new(Request) 99 | err := json.NewDecoder(r.Body).Decode(req) 100 | if err != nil { 101 | http.Error(w, "invalid request: "+err.Error(), http.StatusBadRequest) 102 | return 103 | } 104 | req.raddr = r.RemoteAddr 105 | 106 | res := &response{Version: "2.0", ID: req.ID} 107 | 108 | hf.ServeJSONRPC(res, req) 109 | 110 | w.Header().Set("Content-Type", ContentType) 111 | json.NewEncoder(w).Encode(res) 112 | }) 113 | } 114 | 115 | // Error implements a top-level JSON-RPC error. 116 | type Error struct { 117 | Code int `json:"code"` 118 | Message string `json:"message"` 119 | 120 | Data interface{} `json:"data,omitempty"` 121 | } 122 | 123 | func (e Error) Error() string { 124 | return fmt.Sprintf("Error(%d): %s", e.Code, e.Message) 125 | } 126 | 127 | // ParseError returns a JSON-RPC Parse Error (-32700). 128 | func ParseError(message string, data interface{}) *Error { 129 | return &Error{Code: -32700, Message: message, Data: data} 130 | } 131 | 132 | // InvalidRequest returns a JSON-RPC Invalid Request error (-32600). 133 | func InvalidRequest(message string, data interface{}) *Error { 134 | return &Error{Code: -32600, Message: message, Data: data} 135 | } 136 | 137 | // MethodNotFound returns a JSON-RPC Method Not Found error (-32601). 138 | func MethodNotFound(message string, data interface{}) *Error { 139 | return &Error{Code: -32601, Message: message, Data: data} 140 | } 141 | 142 | // InvalidParams returns a JSON-RPC Invalid Params error (-32602). 143 | func InvalidParams(message string, data interface{}) *Error { 144 | return &Error{Code: -32602, Message: message, Data: data} 145 | } 146 | 147 | // InternalError returns a JSON-RPC Internal Server error (-32603). 148 | func InternalError(message string, data interface{}) *Error { 149 | return &Error{Code: -32603, Message: message, Data: data} 150 | } 151 | 152 | // ServerError returns a JSON-RPC Server Error, which must be given a code 153 | // between -32000 and -32099. 154 | func ServerError(code int, message string, data interface{}) *Error { 155 | return &Error{Code: code, Message: message, Data: data} 156 | } 157 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= 2 | github.com/google/go-cmp v0.5.7 h1:81/ik6ipDQS2aGcBfIN5dHDB36BwrStyeAQquSYCV4o= 3 | github.com/google/go-cmp v0.5.7/go.mod h1:n+brtR0CgQNWTVd5ZUFpTBC8YFBDLK/h/bpaJ8/DtOE= 4 | github.com/josharian/native v1.0.0 h1:Ts/E8zCSEsG17dUqv7joXJFybuMLjQfWE04tsBODTxk= 5 | github.com/josharian/native v1.0.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= 6 | github.com/mdlayher/genetlink v1.2.0 h1:4yrIkRV5Wfk1WfpWTcoOlGmsWgQj3OtQN9ZsbrE+XtU= 7 | github.com/mdlayher/genetlink v1.2.0/go.mod h1:ra5LDov2KrUCZJiAtEvXXZBxGMInICMXIwshlJ+qRxQ= 8 | github.com/mdlayher/netlink v1.6.0 h1:rOHX5yl7qnlpiVkFWoqccueppMtXzeziFjWAjLg6sz0= 9 | github.com/mdlayher/netlink v1.6.0/go.mod h1:0o3PlBmGst1xve7wQ7j/hwpNaFaH4qCRyWCdcZk8/vA= 10 | github.com/mdlayher/socket v0.1.1/go.mod h1:mYV5YIZAfHh4dzDVzI8x8tWLWCliuX8Mon5Awbj+qDs= 11 | github.com/mdlayher/socket v0.2.3 h1:XZA2X2TjdOwNoNPVPclRCURoX/hokBY8nkTmRZFEheM= 12 | github.com/mdlayher/socket v0.2.3/go.mod h1:bz12/FozYNH/VbvC3q7TRIK/Y6dH1kCKsXaUeXi/FmY= 13 | github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721 h1:RlZweED6sbSArvlE924+mUcZuXKLBHA35U7LN621Bws= 14 | github.com/mikioh/ipaddr v0.0.0-20190404000644-d465c8ab6721/go.mod h1:Ickgr2WtCLZ2MDGd4Gr0geeCH5HybhRJbonOgQpvSxc= 15 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 16 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 17 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 18 | golang.org/x/crypto v0.0.0-20220315160706-3147a52a75dd/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 19 | golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4 h1:kUhD7nTDoI3fVd9G4ORWrbV5NY0liEs/Jg2pv5f+bBA= 20 | golang.org/x/crypto v0.0.0-20220411220226-7b82a4e95df4/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4= 21 | golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 22 | golang.org/x/net v0.0.0-20210928044308-7d9f5e0b762b/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 23 | golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= 24 | golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 25 | golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 26 | golang.org/x/net v0.0.0-20220418201149-a630d4f3e7a2 h1:6mzvA99KwZxbOrxww4EvWVQUnN1+xEu9tafK5ZxkYeA= 27 | golang.org/x/net v0.0.0-20220418201149-a630d4f3e7a2/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= 28 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c h1:5KslGYwFpkhGh+Q16bwMP3cOontH8FOep7tGV86Y7SQ= 29 | golang.org/x/sync v0.0.0-20210220032951-036812b2e83c/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= 30 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 31 | golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 32 | golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 33 | golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 34 | golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 35 | golang.org/x/sys v0.0.0-20211103235746-7861aae1554b/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 36 | golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 37 | golang.org/x/sys v0.0.0-20220128215802-99c3d69c2c27/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 38 | golang.org/x/sys v0.0.0-20220315194320-039c03cc5b86/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 39 | golang.org/x/sys v0.0.0-20220319134239-a9b59b0215f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 40 | golang.org/x/sys v0.0.0-20220412211240-33da011f77ad h1:ntjMns5wyP/fN65tdBD4g8J5w8n015+iIIs9rtjXkY0= 41 | golang.org/x/sys v0.0.0-20220412211240-33da011f77ad/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 42 | golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= 43 | golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= 44 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 45 | golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= 46 | golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= 47 | golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= 48 | golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 49 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1 h1:go1bK/D/BFZV2I8cIQd1NKEZ+0owSTG1fDTci4IqFcE= 50 | golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= 51 | golang.zx2c4.com/wintun v0.0.0-20211104114900-415007cec224/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= 52 | golang.zx2c4.com/wireguard v0.0.0-20220407013110-ef5c587f782d h1:q4JksJ2n0fmbXC0Aj0eOs6E0AcPqnKglxWXWFqGD6x0= 53 | golang.zx2c4.com/wireguard v0.0.0-20220407013110-ef5c587f782d/go.mod h1:bVQfyl2sCM/QIIGHpWbFGfHPuDvqnCNkT6MQLTCjO/U= 54 | golang.zx2c4.com/wireguard/wgctrl v0.0.0-20220916014741-473347a5e6e3 h1:ARxNdT6I+00ZyY5yRT/ZECkQti4iGrMZX9dvG/ao/LY= 55 | golang.zx2c4.com/wireguard/wgctrl v0.0.0-20220916014741-473347a5e6e3/go.mod h1:yp4gl6zOlnDGOZeWeDfMwQcsdOIQnMdhuPx9mwwWBL4= 56 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "crypto/tls" 5 | "crypto/x509" 6 | "fmt" 7 | "io/ioutil" 8 | "log" 9 | "net/http" 10 | "os" 11 | "strings" 12 | 13 | "github.com/jamescun/wg-api/server" 14 | "github.com/jamescun/wg-api/server/jsonrpc" 15 | 16 | flag "github.com/spf13/pflag" 17 | "golang.zx2c4.com/wireguard/wgctrl" 18 | ) 19 | 20 | const help = `WG-API presents a JSON-RPC API to a WireGuard device 21 | Usage: wg-api [options] 22 | 23 | Helpers: 24 | --list-devices list wireguard devices on this system and their name to be 25 | given to --device 26 | --version display the version number of WG-API 27 | 28 | Options: 29 | --device= (required) name of WireGuard device to manager 30 | --listen=<[host:]port> address where API server will bind 31 | (default localhost:8080) 32 | --tls enable Transport Layer Security (SSL) on server 33 | --tls-key TLS private key 34 | --tks-cert TLS certificate file 35 | --tls-client-ca enable mutual TLS authentication (mTLS) of the client 36 | --token opaque value provided by the client to authenticate 37 | requests. may be specified multiple times. 38 | 39 | Environment Variables: 40 | WGAPI_TOKENS comma seperated list of authentication tokens, equivalent to 41 | calling --token one or more times. 42 | 43 | Warnings: 44 | WG-API can perform sensitive network operations, as such it should not be 45 | publicly exposed. It should be bound to the local interface only, or 46 | failing that, be behind an authenticating proxy or have mTLS enabled. 47 | Additionally authentication tokens should be configured. 48 | ` 49 | 50 | var Version = "1.0.0" 51 | 52 | var ( 53 | // helpers 54 | listDevices = flag.Bool("list-devices", false, "") 55 | showVersion = flag.Bool("version", false, "") 56 | 57 | // options 58 | deviceName = flag.String("device", "", "") 59 | listenAddr = flag.String("listen", "localhost:8080", "") 60 | enableTLS = flag.Bool("tls", false, "") 61 | tlsKey = flag.String("tls-key", "", "") 62 | tlsCert = flag.String("tls-cert", "", "") 63 | tlsClientCA = flag.String("tls-client-ca", "", "") 64 | authTokens = flag.StringArray("token", nil, "") 65 | ) 66 | 67 | func main() { 68 | flag.Usage = func() { fmt.Println(help) } 69 | flag.Parse() 70 | 71 | switch { 72 | case *listDevices: 73 | client, err := wgctrl.New() 74 | if err != nil { 75 | exitError("could not create WireGuard client: %s", err) 76 | } 77 | 78 | devices, err := client.Devices() 79 | if err != nil { 80 | exitError("could not list WireGuard devices: %s", err) 81 | } 82 | 83 | if len(devices) > 0 { 84 | for _, device := range devices { 85 | fmt.Println(device.Name) 86 | } 87 | } else { 88 | fmt.Println("No WireGuard devices found.") 89 | } 90 | 91 | case *showVersion: 92 | fmt.Println("WG-API Version:", Version) 93 | 94 | default: 95 | client, err := wgctrl.New() 96 | if err != nil { 97 | exitError("could not create WireGuard client: %s", err) 98 | } 99 | 100 | device, err := client.Device(*deviceName) 101 | if os.IsNotExist(err) { 102 | exitError("device %q does not exist", *deviceName) 103 | } else if err != nil { 104 | exitError("could not open WireGuard device %q: %s", *deviceName, err) 105 | } 106 | 107 | svc, err := server.NewServer(client, device.Name) 108 | if err != nil { 109 | exitError("could not create WG-API server: %s", err) 110 | } 111 | 112 | handler := jsonrpc.HTTP(server.Logger(svc)) 113 | 114 | if tokens := envArray("WGAPI_TOKENS"); len(tokens) > 0 { 115 | *authTokens = append(*authTokens, tokens...) 116 | } 117 | 118 | if len(*authTokens) > 0 { 119 | handler = server.AuthTokens(*authTokens...)(handler) 120 | } 121 | 122 | handler = server.PreventReferer(handler) 123 | 124 | s := &http.Server{ 125 | Addr: *listenAddr, 126 | Handler: handler, 127 | } 128 | 129 | if *enableTLS { 130 | if *tlsKey == "" || *tlsCert == "" { 131 | exitError("tls key and cert required for TLS") 132 | } 133 | 134 | if *tlsClientCA != "" { 135 | pool, err := loadCertificatePool(*tlsClientCA) 136 | if err != nil { 137 | exitError("could not load client ca: %s", err) 138 | } 139 | 140 | s.TLSConfig = &tls.Config{ 141 | ClientCAs: pool, 142 | ClientAuth: tls.RequireAndVerifyClientCert, 143 | } 144 | } 145 | 146 | log.Printf("info: server: listening on https://%s\n", s.Addr) 147 | 148 | if err := s.ListenAndServeTLS(*tlsCert, *tlsKey); err != nil { 149 | log.Fatalln("fatal: server:", err) 150 | } 151 | } else { 152 | log.Printf("info: server: listening on http://%s\n", s.Addr) 153 | 154 | if err := s.ListenAndServe(); err != nil { 155 | log.Fatalln("fatal: server:", err) 156 | } 157 | } 158 | } 159 | } 160 | 161 | func exitError(format string, args ...interface{}) { 162 | fmt.Fprintf(os.Stderr, "error: "+format+"\n", args...) 163 | os.Exit(1) 164 | } 165 | 166 | func loadCertificatePool(filename string) (*x509.CertPool, error) { 167 | pemBytes, err := ioutil.ReadFile(filename) 168 | if err != nil { 169 | return nil, err 170 | } 171 | 172 | pool := x509.NewCertPool() 173 | 174 | ok := pool.AppendCertsFromPEM(pemBytes) 175 | if !ok { 176 | return nil, fmt.Errorf("error processing pem certificates") 177 | } 178 | 179 | return pool, nil 180 | } 181 | 182 | func envArray(name string) []string { 183 | env := os.Getenv(name) 184 | if env == "" { 185 | return nil 186 | } 187 | 188 | vv := strings.Split(env, ",") 189 | 190 | for i, v := range vv { 191 | vv[i] = strings.TrimSpace(v) 192 | } 193 | 194 | return vv 195 | } 196 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # WG-API 🔐 2 | 3 | WG-API presents a JSON-RPC interface on top of a WireGuard network interface. 4 | 5 | * 💖 **Add/Remove Peers** 6 | Modify known peers without reloading 7 | 8 | * 📈 **Statistics** 9 | View data usage and allowed IPs of all peers 10 | 11 | * 📞 **JSON-RPC 2.0 API** 12 | No custom client integrations required, standard API accepted everywhere. 13 | 14 | **NOTE:** WG-API is currently only compatible with the WireGuard Linux kernel module and userland wireguard-go. It does not currently work with the MacOS NetworkExtension. 15 | 16 | 17 | ## Getting WG-API 18 | 19 | ### Pre-Built Binary 20 | 21 | Binaries for Linux are available [here](https://github.com/jamescun/wg-api/releases). 22 | 23 | ### Build Yourself 24 | 25 | WG-API requires at least Go 1.17. 26 | 27 | ```sh 28 | go install github.com/jamescun/wg-api 29 | ``` 30 | 31 | This should install the server binary `wg-api` in your $GOPATH/bin. 32 | 33 | ### Docker 34 | 35 | WG-API can also be run inside a Docker container, however the container will need to existing within the same network namespace as the host and have network administrator capability (CAP_NET_ADMIN) to be able to control the WireGuard interface. 36 | 37 | ```sh 38 | docker run --name=wg-api -d -p 8080:8080 --network host --cap-add NET_ADMIN james/wg-api:latest --device= 39 | ``` 40 | 41 | The Docker container now supports Linux on AMD64, ARM64 and ARMv7 architectures. 42 | 43 | ## Configuring WG-API 44 | 45 | WG is configured using command line arguments: 46 | 47 | ```sh 48 | $ wg-api --help 49 | WG-API presents a JSON-RPC API to a WireGuard device 50 | Usage: wg-api [options] 51 | 52 | Helpers: 53 | --list-devices list wireguard devices on this system and their name to be 54 | given to --device 55 | --version display the version number of WG-API 56 | 57 | Options: 58 | --device= (required) name of WireGuard device to manager 59 | --listen=<[host:]port> address where API server will bind 60 | (default localhost:8080) 61 | --tls enable Transport Layer Security (SSL) on server 62 | --tls-key TLS private key 63 | --tks-cert TLS certificate file 64 | --tls-client-ca enable mutual TLS authentication (mTLS) of the client 65 | --token opaque value provided by the client to authenticate 66 | requests. may be specified multiple times. 67 | 68 | Environment Variables: 69 | WGAPI_TOKENS comma seperated list of authentication tokens, equivalent to 70 | calling --token one or more times. 71 | 72 | Warnings: 73 | WG-API can perform sensitive network operations, as such it should not be 74 | publicly exposed. It should be bound to the local interface only, or 75 | failing that, be behind an authenticating proxy or have mTLS enabled. 76 | Additionally authentication tokens should be configured. 77 | ``` 78 | 79 | The only required argument is `--device`, which tells WG-API which WireGuard device to control. To control multiple WireGuard devices, launch multiple instances of WG-API. 80 | 81 | By default, this launches WG-API on `localhost:8080` which may conflict with the typical development environment. To bind it elsewhere, use `--listen`: 82 | 83 | ```sh 84 | $ wg-api --device= --listen=localhost:1234 85 | ``` 86 | 87 | **NOTE:** `--listen` will not prevent you from binding the server to a public interface. Care should be taken to prevent public access to the WG-API server; such as binding it only to a local interface, enabling auth tokens, placing an authenticating reverse proxy in-front of it or using mTLS (detailed below). 88 | 89 | Authentication tokens can be provided either on the command line or via an environment variable. `--token` may be specified multiple times, or a comma-seperated list may be provided with the `WGAPI_TOKENS` environment variable. Environment variables are preferred as the token may be visible from process lists when using the command line `--token`. 90 | 91 | ```sh 92 | $ WGAPI_TOKENS= wg-api --device= 93 | ``` 94 | 95 | Then provided as part of the HTTP exchange in the HTTP `Authorization` header as the `Token` scheme. 96 | 97 | ```sh 98 | $ curl http://localhost:8080 -H "Authorization: Token " ... 99 | ``` 100 | 101 | ``` 102 | POST / HTTP/1.1 103 | Host: localhost:8080 104 | Authorization: Token 105 | Content-Type: application/json 106 | ``` 107 | 108 | WG-API can optional listen using TLS and HTTP/2. To enable TLS, you will also need a TLS Certificate and matching private key. 109 | 110 | ```sh 111 | $ wg-api --device= --tls --tls-key=key.pem --tls-cert=cert.pem 112 | ``` 113 | 114 | And optionally WG-API can request and validate client certificates to implement TLS Mutual Authentication (mTLS): 115 | 116 | ```sh 117 | $ wg-api --device= --tls --tls-key=key.pem --tls-cert=cert.pem --tls-client-ca=clientca.pem 118 | ``` 119 | 120 | 121 | ## Using WG-API 122 | 123 | WG-API exposes a JSON-RPC 2.0 API with five methods. 124 | 125 | All calls are made using the POST method, and require the `Content-Type` header to be set to `application/json`. The server ignores the URL path it is given, allowing the server to be mounted under another hierarchy in a reverse proxy. 126 | 127 | The structures expected by the server can be found in [client/client.go](client/client.go). 128 | 129 | Authentication may optionally be configured. This is supplied via the `Authorization` header as the `Token` scheme. See [Configuring WG-API](##Configuring-WG-API) for an example. 130 | 131 | 132 | ### GetDeviceInfo 133 | 134 | GetDeviceInfo returns information such as the public key and type of interface for the currently configured device. 135 | 136 | ```sh 137 | curl http://localhost:8080 -H "Content-Type: application/json" -d '{"jsonrpc": "2.0", "method": "GetDeviceInfo", "params": {}}' 138 | ``` 139 | 140 | #### Example Response 141 | 142 | ```json 143 | { 144 | "device": { 145 | "name": "wg0", 146 | "type": "Linux kernel", 147 | "public_key": "xoY2MZZ1UmbEakFBPyqryHwTaMi6ae4myP+vuILmJUY=", 148 | "listen_port": 51820, 149 | "num_peers": 13 150 | } 151 | } 152 | ``` 153 | 154 | 155 | ### ListPeers 156 | 157 | ListPeers retrieves information about all Peers known to the current WireGuard interface, including allowed IP addresses and usage stats, optionally with pagination. 158 | 159 | ```sh 160 | curl http://localhost:8080 -H "Content-Type: application/json" -d '{"jsonrpc": "2.0", "method": "ListPeers", "params": {}}' 161 | ``` 162 | 163 | #### Example Response 164 | 165 | ```json 166 | { 167 | "peers": [ 168 | { 169 | "public_key": "xoY2MZZ1UmbEakFBPyqryHwTaMi6ae4myP+vuILmJUY=", 170 | "has_preshared_key": false, 171 | "endpoint": "67.234.65.104:57436", 172 | "last_handshake": "2020-02-20T16:35:12Z", 173 | "receive_bytes": 834854756, 174 | "transmit_bytes": 3883746, 175 | "allowed_ips": [ 176 | "10.1.1.0/24" 177 | ], 178 | "protocol_version": 1 179 | }, 180 | ... 181 | ] 182 | } 183 | ``` 184 | 185 | 186 | ### GetPeer 187 | 188 | GetPeer retrieves a specific Peer by their public key. 189 | 190 | ```sh 191 | curl http://localhost:8080 -H "Content-Type: application/json" -d '{"jsonrpc": "2.0", "method": "GetPeer", "params": {"public_key": "xoY2MZZ1UmbEakFBPyqryHwTaMi6ae4myP+vuILmJUY="}}' 192 | ``` 193 | 194 | #### Example Response 195 | 196 | ```json 197 | { 198 | "peer": { 199 | "public_key": "xoY2MZZ1UmbEakFBPyqryHwTaMi6ae4myP+vuILmJUY=", 200 | "has_preshared_key": false, 201 | "endpoint": "67.234.65.104:57436", 202 | "last_handshake": "2020-02-20T16:35:12Z", 203 | "receive_bytes": 834854756, 204 | "transmit_bytes": 3883746, 205 | "allowed_ips": [ 206 | "10.1.1.0/24" 207 | ], 208 | "protocol_version": 1 209 | } 210 | } 211 | ``` 212 | 213 | 214 | ### AddPeer 215 | 216 | AddPeer inserts a new Peer into the WireGuard interfaces table, multiple calls to AddPeer can be used to update details of the Peer. 217 | 218 | ```sh 219 | curl http://localhost:8080 -H "Content-Type: application/json" -d '{"jsonrpc": "2.0", "method": "AddPeer", "params": {"public_key": "xoY2MZZ1UmbEakFBPyqryHwTaMi6ae4myP+vuILmJUY=","allowed_ips": [ "10.1.1.0/24" ]}}' 220 | ``` 221 | 222 | 223 | ### RemovePeer 224 | 225 | RemovePeer deletes a Peer from the WireGuard interfaces table by their public key, 226 | 227 | ```sh 228 | curl http://localhost:8080 -H "Content-Type: application/json" -d '{"jsonrpc": "2.0", "method": "RemovePeer", "params": {"public_key": "xoY2MZZ1UmbEakFBPyqryHwTaMi6ae4myP+vuILmJUY="}}' 229 | ``` 230 | 231 | ## Thanks 232 | 233 | With many thanks to: 234 | 235 | - [Jason A. Donenfeld](https://github.com/zx2c4) 236 | -------------------------------------------------------------------------------- /server/server.go: -------------------------------------------------------------------------------- 1 | package server 2 | 3 | import ( 4 | "context" 5 | "encoding/json" 6 | "fmt" 7 | "net" 8 | "time" 9 | 10 | "github.com/jamescun/wg-api/client" 11 | "github.com/jamescun/wg-api/server/jsonrpc" 12 | 13 | "golang.zx2c4.com/wireguard/wgctrl" 14 | "golang.zx2c4.com/wireguard/wgctrl/wgtypes" 15 | ) 16 | 17 | // Server is the host-side implementation of the WG-API Client. It supports 18 | // both Kernel and Userland implementations of WireGuard. 19 | type Server struct { 20 | wg *wgctrl.Client 21 | deviceName string 22 | } 23 | 24 | // NewServer initializes a Server with a WireGuard client. 25 | func NewServer(wg *wgctrl.Client, deviceName string) (*Server, error) { 26 | return &Server{wg: wg, deviceName: deviceName}, nil 27 | } 28 | 29 | // GetDeviceInfo returns information such as the public key and type of 30 | // interface for the currently configured device. 31 | func (s *Server) GetDeviceInfo(ctx context.Context, req *client.GetDeviceInfoRequest) (*client.GetDeviceInfoResponse, error) { 32 | dev, err := s.wg.Device(s.deviceName) 33 | if err != nil { 34 | return nil, fmt.Errorf("could not get WireGuard device: %w", err) 35 | } 36 | 37 | return &client.GetDeviceInfoResponse{ 38 | Device: &client.Device{ 39 | Name: dev.Name, 40 | Type: dev.Type.String(), 41 | PublicKey: dev.PublicKey.String(), 42 | ListenPort: dev.ListenPort, 43 | FirewallMark: dev.FirewallMark, 44 | NumPeers: len(dev.Peers), 45 | }, 46 | }, nil 47 | } 48 | 49 | func validateListPeersRequest(req *client.ListPeersRequest) error { 50 | if req == nil { 51 | return jsonrpc.InvalidParams("request body required", nil) 52 | } 53 | 54 | if req.Limit < 0 { 55 | return jsonrpc.InvalidParams("limit must be positive integer", nil) 56 | } else if req.Offset < 0 { 57 | return jsonrpc.InvalidParams("offset must be positive integer", nil) 58 | } 59 | 60 | return nil 61 | } 62 | 63 | // ListPeers retrieves information about all Peers known to the current 64 | // WireGuard interface, including allowed IP addresses and usage stats, 65 | // optionally with pagination. 66 | func (s *Server) ListPeers(ctx context.Context, req *client.ListPeersRequest) (*client.ListPeersResponse, error) { 67 | if err := validateListPeersRequest(req); err != nil { 68 | return nil, err 69 | } 70 | 71 | dev, err := s.wg.Device(s.deviceName) 72 | if err != nil { 73 | return nil, fmt.Errorf("could not get WireGuard device: %w", err) 74 | } 75 | 76 | var peers []*client.Peer 77 | 78 | for _, peer := range dev.Peers { 79 | peers = append(peers, peer2rpc(peer)) 80 | } 81 | 82 | // TODO(jc): pagination 83 | 84 | return &client.ListPeersResponse{ 85 | Peers: peers, 86 | }, nil 87 | } 88 | 89 | func peer2rpc(peer wgtypes.Peer) *client.Peer { 90 | var keepAlive string 91 | if peer.PersistentKeepaliveInterval > 0 { 92 | keepAlive = peer.PersistentKeepaliveInterval.String() 93 | } 94 | 95 | var allowedIPs []string 96 | for _, allowedIP := range peer.AllowedIPs { 97 | allowedIPs = append(allowedIPs, allowedIP.String()) 98 | } 99 | 100 | return &client.Peer{ 101 | PublicKey: peer.PublicKey.String(), 102 | HasPresharedKey: peer.PresharedKey != wgtypes.Key{}, 103 | Endpoint: peer.Endpoint.String(), 104 | PersistentKeepAlive: keepAlive, 105 | LastHandshake: peer.LastHandshakeTime, 106 | ReceiveBytes: peer.ReceiveBytes, 107 | TransmitBytes: peer.TransmitBytes, 108 | AllowedIPs: allowedIPs, 109 | ProtocolVersion: peer.ProtocolVersion, 110 | } 111 | } 112 | 113 | func validateGetPeerRequest(req *client.GetPeerRequest) error { 114 | if req == nil { 115 | return jsonrpc.InvalidParams("request body required", nil) 116 | } 117 | 118 | if req.PublicKey == "" { 119 | return jsonrpc.InvalidParams("public key is required", nil) 120 | } else if len(req.PublicKey) != 44 { 121 | return jsonrpc.InvalidParams("malformed public key", nil) 122 | } 123 | 124 | _, err := wgtypes.ParseKey(req.PublicKey) 125 | if err != nil { 126 | return jsonrpc.InvalidParams("invalid public key: "+err.Error(), nil) 127 | } 128 | 129 | return nil 130 | } 131 | 132 | // GetPeer retrieves a specific Peer by their public key. 133 | func (s *Server) GetPeer(ctx context.Context, req *client.GetPeerRequest) (*client.GetPeerResponse, error) { 134 | if err := validateGetPeerRequest(req); err != nil { 135 | return nil, err 136 | } 137 | 138 | dev, err := s.wg.Device(s.deviceName) 139 | if err != nil { 140 | return nil, fmt.Errorf("could not get WireGuard device: %w", err) 141 | } 142 | 143 | publicKey, err := wgtypes.ParseKey(req.PublicKey) 144 | if err != nil { 145 | return nil, jsonrpc.InvalidParams("invalid public key: "+err.Error(), nil) 146 | } 147 | 148 | for _, peer := range dev.Peers { 149 | if peer.PublicKey == publicKey { 150 | return &client.GetPeerResponse{ 151 | Peer: peer2rpc(peer), 152 | }, nil 153 | } 154 | } 155 | 156 | return &client.GetPeerResponse{}, nil 157 | } 158 | 159 | func validateAddPeerRequest(req *client.AddPeerRequest) error { 160 | if req == nil { 161 | return jsonrpc.InvalidParams("request body required", nil) 162 | } 163 | 164 | if req.PublicKey == "" { 165 | return jsonrpc.InvalidParams("public key is required", nil) 166 | } else if len(req.PublicKey) != 44 { 167 | return jsonrpc.InvalidParams("malformed public key", nil) 168 | } 169 | 170 | _, err := wgtypes.ParseKey(req.PublicKey) 171 | if err != nil { 172 | return jsonrpc.InvalidParams("invalid public key: "+err.Error(), nil) 173 | } 174 | 175 | if req.PresharedKey != "" { 176 | if len(req.PresharedKey) != 44 { 177 | return jsonrpc.InvalidParams("malformed preshared key", nil) 178 | } 179 | 180 | _, err := wgtypes.ParseKey(req.PresharedKey) 181 | if err != nil { 182 | return jsonrpc.InvalidParams("invalid preshared key: "+err.Error(), nil) 183 | } 184 | } 185 | 186 | if req.Endpoint != "" { 187 | _, err := net.ResolveUDPAddr("udp", req.Endpoint) 188 | if err != nil { 189 | return jsonrpc.InvalidParams("invalid endpoint: "+err.Error(), nil) 190 | } 191 | } 192 | 193 | if req.PersistentKeepAlive != "" { 194 | _, err := time.ParseDuration(req.PersistentKeepAlive) 195 | if err != nil { 196 | return jsonrpc.InvalidParams("invalid keepalive: "+err.Error(), nil) 197 | } 198 | } 199 | 200 | for _, allowedIP := range req.AllowedIPs { 201 | _, _, err := net.ParseCIDR(allowedIP) 202 | if err != nil { 203 | return jsonrpc.InvalidParams(fmt.Sprintf("range %q is not valid: %s", allowedIP, err), nil) 204 | } 205 | } 206 | 207 | return nil 208 | } 209 | 210 | // AddPeer inserts a new Peer into the WireGuard interfaces table, multiple 211 | // calls to AddPeer can be used to update details of the Peer. 212 | func (s *Server) AddPeer(ctx context.Context, req *client.AddPeerRequest) (*client.AddPeerResponse, error) { 213 | if err := validateAddPeerRequest(req); err != nil { 214 | return nil, err 215 | } else if req.ValidateOnly { 216 | return &client.AddPeerResponse{}, nil 217 | } 218 | 219 | publicKey, err := wgtypes.ParseKey(req.PublicKey) 220 | if err != nil { 221 | return nil, jsonrpc.InvalidParams("invalid public key: "+err.Error(), nil) 222 | } 223 | 224 | peer := wgtypes.PeerConfig{PublicKey: publicKey} 225 | 226 | if req.PresharedKey != "" { 227 | pk, err := wgtypes.ParseKey(req.PresharedKey) 228 | if err != nil { 229 | return nil, jsonrpc.InvalidParams("invalid preshared key: "+err.Error(), nil) 230 | } 231 | 232 | peer.PresharedKey = &pk 233 | } 234 | 235 | if req.Endpoint != "" { 236 | addr, err := net.ResolveUDPAddr("udp", req.Endpoint) 237 | if err != nil { 238 | return nil, jsonrpc.InvalidParams("invalid endpoint: "+err.Error(), nil) 239 | } 240 | 241 | peer.Endpoint = addr 242 | } 243 | 244 | if req.PersistentKeepAlive != "" { 245 | d, err := time.ParseDuration(req.PersistentKeepAlive) 246 | if err != nil { 247 | return nil, jsonrpc.InvalidParams("invalid keepalive: "+err.Error(), nil) 248 | } 249 | 250 | peer.PersistentKeepaliveInterval = &d 251 | } 252 | 253 | for _, allowedIP := range req.AllowedIPs { 254 | _, aip, err := net.ParseCIDR(allowedIP) 255 | if err != nil { 256 | return nil, jsonrpc.InvalidParams(fmt.Sprintf("range %q is not valid: %s", allowedIP, err), nil) 257 | } 258 | 259 | peer.AllowedIPs = append(peer.AllowedIPs, *aip) 260 | } 261 | 262 | err = s.wg.ConfigureDevice(s.deviceName, wgtypes.Config{Peers: []wgtypes.PeerConfig{peer}}) 263 | if err != nil { 264 | return nil, fmt.Errorf("could not configure WireGuard device: %w", err) 265 | } 266 | 267 | return &client.AddPeerResponse{OK: true}, nil 268 | } 269 | 270 | func validateRemovePeerRequest(req *client.RemovePeerRequest) error { 271 | if req == nil { 272 | return jsonrpc.InvalidParams("request body required", nil) 273 | } 274 | 275 | if req.PublicKey == "" { 276 | return jsonrpc.InvalidParams("public key is required", nil) 277 | } else if len(req.PublicKey) != 44 { 278 | return jsonrpc.InvalidParams("malformed public key", nil) 279 | } 280 | 281 | _, err := wgtypes.ParseKey(req.PublicKey) 282 | if err != nil { 283 | return jsonrpc.InvalidParams("invalid public key: "+err.Error(), nil) 284 | } 285 | 286 | return nil 287 | } 288 | 289 | // RemovePeer deletes a Peer from the WireGuard interfaces table by their 290 | // public key, 291 | func (s *Server) RemovePeer(ctx context.Context, req *client.RemovePeerRequest) (*client.RemovePeerResponse, error) { 292 | if err := validateRemovePeerRequest(req); err != nil { 293 | return nil, err 294 | } else if req.ValidateOnly { 295 | return &client.RemovePeerResponse{}, nil 296 | } 297 | 298 | publicKey, err := wgtypes.ParseKey(req.PublicKey) 299 | if err != nil { 300 | return nil, fmt.Errorf("invalid public key: %w", err) 301 | } 302 | 303 | peer := wgtypes.PeerConfig{ 304 | PublicKey: publicKey, 305 | Remove: true, 306 | } 307 | 308 | err = s.wg.ConfigureDevice(s.deviceName, wgtypes.Config{Peers: []wgtypes.PeerConfig{peer}}) 309 | if err != nil { 310 | return nil, fmt.Errorf("could not configure WireGuard device: %w", err) 311 | } 312 | 313 | return &client.RemovePeerResponse{OK: true}, nil 314 | } 315 | 316 | // ServeJSONRPC handles incoming WG-API requests. 317 | func (s *Server) ServeJSONRPC(w jsonrpc.ResponseWriter, r *jsonrpc.Request) { 318 | var res interface{} 319 | 320 | // TODO(jc): must be a way to make this generic, reflection maybe? 321 | 322 | switch r.Method { 323 | case "GetDeviceInfo": 324 | var err error 325 | res, err = s.GetDeviceInfo(r.Context(), &client.GetDeviceInfoRequest{}) 326 | if err != nil { 327 | res = jsonrpc.ServerError(-32000, err.Error(), nil) 328 | } 329 | 330 | case "ListPeers": 331 | var arg client.ListPeersRequest 332 | err := json.Unmarshal(r.Params, &arg) 333 | if err != nil { 334 | res = jsonrpc.ParseError(err.Error(), nil) 335 | } else { 336 | res, err = s.ListPeers(r.Context(), &arg) 337 | if err != nil { 338 | res = jsonrpc.ServerError(-32000, err.Error(), nil) 339 | } 340 | } 341 | 342 | case "GetPeer": 343 | var arg client.GetPeerRequest 344 | err := json.Unmarshal(r.Params, &arg) 345 | if err != nil { 346 | res = jsonrpc.ParseError(err.Error(), nil) 347 | } else { 348 | res, err = s.GetPeer(r.Context(), &arg) 349 | if err != nil { 350 | res = jsonrpc.ServerError(-32000, err.Error(), nil) 351 | } 352 | } 353 | 354 | case "AddPeer": 355 | var arg client.AddPeerRequest 356 | err := json.Unmarshal(r.Params, &arg) 357 | if err != nil { 358 | res = jsonrpc.ParseError(err.Error(), nil) 359 | } else { 360 | res, err = s.AddPeer(r.Context(), &arg) 361 | if err != nil { 362 | res = jsonrpc.ServerError(-32000, err.Error(), nil) 363 | } 364 | } 365 | 366 | case "RemovePeer": 367 | var arg client.RemovePeerRequest 368 | err := json.Unmarshal(r.Params, &arg) 369 | if err != nil { 370 | res = jsonrpc.ParseError(err.Error(), nil) 371 | } else { 372 | res, err = s.RemovePeer(r.Context(), &arg) 373 | if err != nil { 374 | res = jsonrpc.ServerError(-32000, err.Error(), nil) 375 | } 376 | } 377 | 378 | default: 379 | res = jsonrpc.MethodNotFound("method not found", nil) 380 | } 381 | 382 | w.Write(res) 383 | } 384 | --------------------------------------------------------------------------------