├── .travis.yml ├── Makefile ├── README.md ├── cmd ├── wakeup │ └── main.go └── wakeupbr │ └── main.go ├── go.mod ├── go.sum ├── http ├── http.go └── http_test.go ├── static ├── .jshintrc ├── app.js ├── index.html └── screenshot.png └── wol ├── bridge.go ├── bridge_test.go ├── wol.go └── wol_test.go /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - stable 5 | 6 | env: 7 | - GO111MODULE=on 8 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | XGOARCH := arm 2 | XGOOS := linux 3 | XBINS := $(XGOOS)_$(XGOARCH)/wakeup $(XGOOS)_$(XGOARCH)/wakeupbr 4 | 5 | .PHONY: $(XBINS) 6 | 7 | all: lint test install 8 | 9 | test: 10 | go test ./... 11 | 12 | vet: 13 | go vet ./... 14 | 15 | check-fmt: 16 | bash -c "diff --line-format='%L' <(echo -n) <(gofmt -d -s .)" 17 | 18 | lint: check-fmt vet 19 | 20 | install: 21 | go install ./... 22 | 23 | xinstall: 24 | env GOOS=$(XGOOS) GOARCH=$(XGOARCH) go install ./... 25 | 26 | publish: $(XBINS) 27 | 28 | $(XBINS): 29 | ifndef DEST_PATH 30 | $(error DEST_PATH must be set when publishing) 31 | endif 32 | rsync -a $(GOPATH)/bin/$@ $(DEST_PATH)/$@ 33 | @sha256sum $(GOPATH)/bin/$@ 34 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # wakeup 2 | 3 | [![Build Status](https://travis-ci.org/mpolden/wakeup.svg?branch=master)](https://travis-ci.org/mpolden/wakeup) 4 | 5 | `wakeup` provides a small HTTP API and JavaScript front-end for 6 | sending [Wake-on-LAN](https://en.wikipedia.org/wiki/Wake-on-LAN) messages to a 7 | target device. 8 | 9 | ## `wakeup` usage 10 | 11 | ``` 12 | $ wakeup -h 13 | Usage: 14 | wakeup [OPTIONS] 15 | 16 | Application Options: 17 | -c, --cache=FILE Path to cache file 18 | -b, --bind=IP IP address to bind to when sending WOL packets 19 | -l, --listen=ADDR Listen address (default: :8080) 20 | -s, --static=DIR Path to directory containing static assets 21 | 22 | Help Options: 23 | -h, --help Show this help message 24 | ``` 25 | 26 | ## `wakeupbr` usage 27 | 28 | ``` 29 | $ wakeupbr -h 30 | Usage: 31 | wakeupbr [OPTIONS] 32 | 33 | Application Options: 34 | -l, --listen=IP Listen address to use when listening for WOL packets (default: 0.0.0.0:9) 35 | -o, --forward=IP Address of interface where received WOL packets should be forwarded 36 | 37 | Help Options: 38 | -h, --help Show this help message 39 | ``` 40 | 41 | ## API 42 | 43 | Wake a device: 44 | 45 | `$ curl -XPOST -d '{"macAddress":"AB:CD:EF:12:34:56"}' http://localhost:8080/api/v1/wake` 46 | 47 | A name for the device can also be provided, to make it easy to identify later: 48 | 49 | `$ curl -XPOST -d '{"name":"foo","macAddress":"AB:CD:EF:12:34:56"}' http://localhost:8080/api/v1/wake` 50 | 51 | List devices that have previously been woken: 52 | 53 | ``` 54 | $ curl -s http://localhost:8080/api/v1/wake | jq . 55 | { 56 | "devices": [ 57 | { 58 | "name": "foo", 59 | "macAddress": "AB:CD:EF:12:34:56" 60 | } 61 | ] 62 | } 63 | ``` 64 | 65 | Delete a device: 66 | 67 | `$ curl -XDELETE -d '{"macAddress":"AB:CD:EF:12:34:56"}' http://localhost:8080/api/v1/wake` 68 | 69 | ## Front-end 70 | 71 | A basic JavaScript front-end is included. It can be served by `wakeup` by 72 | passing the path to `static` as the `-s` option. 73 | 74 | ![Front-end screenshot](static/screenshot.png) 75 | 76 | ## Bridge 77 | 78 | The `wakeupbr` program acts as bridge for Wake-on-LAN packets. The program 79 | listens for Wake-on-LAN packets on the incoming interface and forwards any 80 | received packets to the outgoing interface. 81 | 82 | Example: 83 | 84 | A device has two interfaces, one wired (`eth0`) with the address `172.16.0.10` 85 | and one wireless (`wlan0`) with the address `10.0.0.10`. The device we want to 86 | wake is on the wired network. We want to pick up Wake-on-LAN packets that are 87 | received on the wireless network and send them out on the wired network. This 88 | can be accomplished with the following command: 89 | 90 | ``` 91 | $ wakeupbr -l 10.0.0.10 -o 172.16.0.10 92 | ``` 93 | 94 | Any Wake-on-LAN packet that is broadcast on the wireless network will then be 95 | forwarded. When a packet is received and forwarded, a message will be logged: 96 | 97 | ``` 98 | 2017/07/28 19:34:54 Forwarded magic packet for AA:BB:CC:12:34:56 to 172.16.0.10 99 | ``` 100 | 101 | The command above listens on UDP port 9 for Wake-on-LAN packets. As port 9 is a 102 | privileged port, `wakeupbr` must be run as root. This is less than ideal, but 103 | Wake-on-LAN packets are always broadcast to port 9. To avoid binding to a 104 | privileged port we can use a `iptables` rule: 105 | 106 | ``` 107 | $ iptables -t nat -A PREROUTING -i wlan0 -p udp --dport 9 -j REDIRECT --to-port 9000 108 | ``` 109 | 110 | You should also ensure that traffic to UDP port 9000 is accepted by the `INPUT` 111 | chain: 112 | 113 | ``` 114 | $ iptables -A INPUT -p udp --dport 9000 -j ACCEPT 115 | ``` 116 | 117 | This will redirect all packets on UDP port 9 to port 9000. `wakeupbr` can then 118 | listen on port 9000 and run as a regular user: 119 | 120 | ``` 121 | $ wakeupbr -l 10.0.0.10:9000 -o 172.16.0.10 122 | ``` 123 | -------------------------------------------------------------------------------- /cmd/wakeup/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net" 6 | "os" 7 | "strings" 8 | 9 | flags "github.com/jessevdk/go-flags" 10 | "github.com/mpolden/wakeup/http" 11 | ) 12 | 13 | func main() { 14 | var opts struct { 15 | CacheFile string `short:"c" long:"cache" description:"Path to cache file" required:"true" value-name:"FILE"` 16 | SourceIP string `short:"b" long:"bind" description:"IP address to bind to when sending WOL packets" value-name:"IP"` 17 | Listen string `short:"l" long:"listen" description:"Listen address" value-name:"ADDR" default:":8080"` 18 | StaticDir string `short:"s" long:"static" description:"Path to directory containing static assets" value-name:"DIR"` 19 | } 20 | _, err := flags.ParseArgs(&opts, os.Args) 21 | if err != nil { 22 | os.Exit(1) 23 | } 24 | 25 | sourceIP := net.ParseIP(opts.SourceIP) 26 | if opts.SourceIP != "" && sourceIP == nil { 27 | log.Fatalf("invalid ip: %s", opts.SourceIP) 28 | } 29 | 30 | server := http.New(opts.CacheFile) 31 | server.StaticDir = opts.StaticDir 32 | server.SourceIP = sourceIP 33 | if strings.HasPrefix(opts.Listen, ":") { 34 | log.Printf("Serving at http://0.0.0.0%s", opts.Listen) 35 | } else { 36 | log.Printf("Serving at http://%s", opts.Listen) 37 | } 38 | if err := server.ListenAndServe(opts.Listen); err != nil { 39 | log.Fatal(err) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /cmd/wakeupbr/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "net" 6 | "os" 7 | "strings" 8 | 9 | flags "github.com/jessevdk/go-flags" 10 | "github.com/mpolden/wakeup/wol" 11 | ) 12 | 13 | func main() { 14 | var opts struct { 15 | ListenAddr string `short:"l" long:"listen" description:"Listen address to use when listening for WOL packets" value-name:"IP" default:"0.0.0.0:9"` 16 | ForwardAddr string `short:"o" long:"forward" description:"Address of interface where received WOL packets should be forwarded" required:"true" value-name:"IP"` 17 | } 18 | _, err := flags.ParseArgs(&opts, os.Args) 19 | if err != nil { 20 | os.Exit(1) 21 | } 22 | 23 | forwardAddr := net.ParseIP(opts.ForwardAddr) 24 | if forwardAddr == nil { 25 | log.Fatalf("invalid ip: %s", opts.ForwardAddr) 26 | } 27 | 28 | b, err := wol.Listen(opts.ListenAddr) 29 | if err != nil { 30 | log.Fatal(err) 31 | } 32 | for { 33 | sent, err := b.Forward(forwardAddr) 34 | if err != nil { 35 | log.Fatal(err) 36 | } 37 | if sent != nil { 38 | log.Printf("Forwarded magic packet for %s to %s", strings.ToUpper(sent.HardwareAddr().String()), forwardAddr) 39 | } 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/mpolden/wakeup 2 | 3 | go 1.13 4 | 5 | require github.com/jessevdk/go-flags v1.4.0 6 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/jessevdk/go-flags v1.4.0 h1:4IU2WS7AumrZ/40jfhf4QVDMsQwqA7VEHozFRrGARJA= 2 | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 3 | -------------------------------------------------------------------------------- /http/http.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "io" 7 | "io/ioutil" 8 | "log" 9 | "net" 10 | "net/http" 11 | "os" 12 | "sort" 13 | "strings" 14 | "sync" 15 | 16 | "github.com/mpolden/wakeup/wol" 17 | ) 18 | 19 | type wakeFunc func(net.IP, net.HardwareAddr) error 20 | 21 | type Server struct { 22 | SourceIP net.IP 23 | StaticDir string 24 | cacheFile string 25 | mu sync.RWMutex 26 | wakeFunc 27 | } 28 | 29 | type Error struct { 30 | err error 31 | Status int `json:"status"` 32 | Message string `json:"message"` 33 | } 34 | 35 | type Devices struct { 36 | Devices []Device `json:"devices"` 37 | } 38 | 39 | type Device struct { 40 | Name string `json:"name,omitempty"` 41 | MACAddress string `json:"macAddress"` 42 | } 43 | 44 | func (d *Devices) add(device Device) { 45 | for _, v := range d.Devices { 46 | if device.MACAddress == v.MACAddress { 47 | return 48 | } 49 | } 50 | d.Devices = append(d.Devices, device) 51 | } 52 | 53 | func (d *Devices) remove(device Device) { 54 | var keep []Device 55 | for _, v := range d.Devices { 56 | if device.MACAddress == v.MACAddress { 57 | continue 58 | } 59 | keep = append(keep, v) 60 | } 61 | d.Devices = keep 62 | } 63 | 64 | func New(cacheFile string) *Server { return &Server{cacheFile: cacheFile, wakeFunc: wol.Wake} } 65 | 66 | func (s *Server) readDevices() (*Devices, error) { 67 | f, err := os.OpenFile(s.cacheFile, os.O_CREATE|os.O_RDONLY, 0644) 68 | if err != nil { 69 | return nil, err 70 | } 71 | defer f.Close() 72 | data, err := ioutil.ReadAll(f) 73 | if err != nil { 74 | return nil, err 75 | } 76 | var i Devices 77 | if len(data) == 0 { 78 | i.Devices = make([]Device, 0) 79 | return &i, nil 80 | } 81 | if err := json.Unmarshal(data, &i); err != nil { 82 | return nil, err 83 | } 84 | if i.Devices == nil { 85 | i.Devices = make([]Device, 0) 86 | } 87 | sort.Slice(i.Devices, func(j, k int) bool { return i.Devices[j].MACAddress < i.Devices[k].MACAddress }) 88 | return &i, nil 89 | } 90 | 91 | func (s *Server) writeDevice(device Device, add bool) error { 92 | i, err := s.readDevices() 93 | if err != nil { 94 | return err 95 | } 96 | f, err := os.OpenFile(s.cacheFile, os.O_CREATE|os.O_RDWR|os.O_TRUNC, 0644) 97 | if err != nil { 98 | return err 99 | } 100 | defer f.Close() 101 | if add { 102 | i.add(device) 103 | } else { 104 | i.remove(device) 105 | } 106 | enc := json.NewEncoder(f) 107 | if err := enc.Encode(i); err != nil && err != io.EOF { 108 | return err 109 | } 110 | return nil 111 | } 112 | 113 | func (s *Server) defaultHandler(w http.ResponseWriter, r *http.Request) (interface{}, *Error) { 114 | defer r.Body.Close() 115 | if r.Method == http.MethodGet { 116 | s.mu.RLock() 117 | defer s.mu.RUnlock() 118 | i, err := s.readDevices() 119 | if err != nil { 120 | return nil, &Error{err: err, Status: http.StatusInternalServerError, Message: "Could not unmarshal JSON"} 121 | } 122 | return i, nil 123 | } 124 | add := r.Method == http.MethodPost 125 | remove := r.Method == http.MethodDelete 126 | if add || remove { 127 | dec := json.NewDecoder(r.Body) 128 | var device Device 129 | if err := dec.Decode(&device); err != nil { 130 | return nil, &Error{Status: http.StatusBadRequest, Message: "Malformed JSON"} 131 | } 132 | if add { 133 | macAddress, err := net.ParseMAC(device.MACAddress) 134 | if err != nil { 135 | return nil, &Error{Status: http.StatusBadRequest, Message: fmt.Sprintf("Invalid MAC address: %s", device.MACAddress)} 136 | } 137 | if err := s.wakeFunc(s.SourceIP, macAddress); err != nil { 138 | return nil, &Error{Status: http.StatusBadRequest, Message: fmt.Sprintf("Failed to wake device with address %s", device.MACAddress)} 139 | } 140 | } 141 | s.mu.Lock() 142 | defer s.mu.Unlock() 143 | if err := s.writeDevice(device, add); err != nil { 144 | return nil, &Error{err: err, Status: http.StatusInternalServerError, Message: "Could not unmarshal JSON"} 145 | } 146 | w.WriteHeader(http.StatusNoContent) 147 | return nil, nil 148 | } 149 | return nil, &Error{ 150 | Status: http.StatusMethodNotAllowed, 151 | Message: fmt.Sprintf("Invalid method %s, must be %s or %s", r.Method, http.MethodGet, http.MethodPost), 152 | } 153 | } 154 | 155 | func notFoundHandler(w http.ResponseWriter, r *http.Request) (interface{}, *Error) { 156 | return nil, &Error{ 157 | Status: http.StatusNotFound, 158 | Message: "Resource not found", 159 | } 160 | } 161 | 162 | type appHandler func(http.ResponseWriter, *http.Request) (interface{}, *Error) 163 | 164 | func (fn appHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { 165 | data, e := fn(w, r) 166 | if e != nil { // e is *Error, not os.Error. 167 | if e.err != nil { 168 | log.Print(e.err) 169 | } 170 | out, err := json.Marshal(e) 171 | if err != nil { 172 | panic(err) 173 | } 174 | w.WriteHeader(e.Status) 175 | w.Write(out) 176 | } else if data != nil { 177 | out, err := json.Marshal(data) 178 | if err != nil { 179 | panic(err) 180 | } 181 | w.Write(out) 182 | } 183 | } 184 | 185 | func requestFilter(next http.Handler) http.Handler { 186 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 187 | if strings.HasPrefix(r.URL.Path, "/api/") { 188 | w.Header().Set("Content-Type", "application/json") 189 | } 190 | next.ServeHTTP(w, r) 191 | }) 192 | } 193 | 194 | func (s *Server) Handler() http.Handler { 195 | mux := http.NewServeMux() 196 | mux.Handle("/api/v1/wake", appHandler(s.defaultHandler)) 197 | // Return 404 in JSON for all unknown requests under /api/ 198 | mux.Handle("/api/", appHandler(notFoundHandler)) 199 | if s.StaticDir != "" { 200 | fs := http.FileServer(http.Dir(s.StaticDir)) 201 | mux.Handle("/", fs) 202 | } 203 | return requestFilter(mux) 204 | } 205 | 206 | func (s *Server) ListenAndServe(addr string) error { 207 | return http.ListenAndServe(addr, s.Handler()) 208 | } 209 | -------------------------------------------------------------------------------- /http/http_test.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "io/ioutil" 5 | "log" 6 | "net" 7 | "net/http" 8 | "net/http/httptest" 9 | "os" 10 | "strings" 11 | "testing" 12 | ) 13 | 14 | func httpGet(url string) (string, int, error) { 15 | res, err := http.Get(url) 16 | if err != nil { 17 | return "", 0, err 18 | } 19 | defer res.Body.Close() 20 | data, err := ioutil.ReadAll(res.Body) 21 | if err != nil { 22 | return "", 0, err 23 | } 24 | return string(data), res.StatusCode, nil 25 | } 26 | 27 | func httpRequest(method, url, body string) (string, int, error) { 28 | r, err := http.NewRequest(method, url, strings.NewReader(body)) 29 | if err != nil { 30 | return "", 0, err 31 | } 32 | res, err := http.DefaultClient.Do(r) 33 | if err != nil { 34 | return "", 0, err 35 | } 36 | defer res.Body.Close() 37 | data, err := ioutil.ReadAll(res.Body) 38 | if err != nil { 39 | return "", 0, err 40 | } 41 | return string(data), res.StatusCode, nil 42 | } 43 | 44 | func httpPost(url, body string) (string, int, error) { 45 | return httpRequest(http.MethodPost, url, body) 46 | } 47 | 48 | func httpDelete(url, body string) (string, int, error) { 49 | return httpRequest(http.MethodDelete, url, body) 50 | } 51 | 52 | func testServer() (*httptest.Server, string) { 53 | file, err := ioutil.TempFile("", "wakeonlan") 54 | if err != nil { 55 | panic(err) 56 | } 57 | api := Server{ 58 | wakeFunc: func(net.IP, net.HardwareAddr) error { return nil }, 59 | cacheFile: file.Name(), 60 | } 61 | log.SetOutput(ioutil.Discard) 62 | return httptest.NewServer(api.Handler()), file.Name() 63 | } 64 | 65 | func TestRequests(t *testing.T) { 66 | server, cacheFile := testServer() 67 | defer os.Remove(cacheFile) 68 | defer server.Close() 69 | 70 | var tests = []struct { 71 | method string 72 | body string 73 | url string 74 | response string 75 | status int 76 | }{ 77 | // Unknown resources 78 | {"GET", "", "/not-found", "404 page not found\n", 404}, 79 | {"GET", "", "/api/not-found", `{"status":404,"message":"Resource not found"}`, 404}, 80 | // Invalid JSON 81 | {"POST", "", "/api/v1/wake", `{"status":400,"message":"Malformed JSON"}`, 400}, 82 | // Invalid MAC address 83 | {"POST", `{"macAddress":"foo"}`, "/api/v1/wake", `{"status":400,"message":"Invalid MAC address: foo"}`, 400}, 84 | // List devices 85 | {"GET", "", "/api/v1/wake", `{"devices":[]}`, 200}, 86 | // Wake device 87 | {"POST", `{"macAddress":"AB:CD:EF:12:34:56"}`, "/api/v1/wake", "", 204}, 88 | {"GET", "", "/api/v1/wake", `{"devices":[{"macAddress":"AB:CD:EF:12:34:56"}]}`, 200}, 89 | // Waking same device does not result in duplicates 90 | {"POST", `{"macAddress":"AB:CD:EF:12:34:56"}`, "/api/v1/wake", "", 204}, 91 | {"GET", "", "/api/v1/wake", `{"devices":[{"macAddress":"AB:CD:EF:12:34:56"}]}`, 200}, 92 | // Delete 93 | {"DELETE", `{"macAddress":"AB:CD:EF:12:34:56"}`, "/api/v1/wake", "", 204}, 94 | {"GET", "", "/api/v1/wake", `{"devices":[]}`, 200}, 95 | // Add multiple devices 96 | {"POST", `{"macAddress":"AB:CD:EF:12:34:56"}`, "/api/v1/wake", "", 204}, 97 | {"POST", `{"macAddress":"12:34:56:AB:CD:EF"}`, "/api/v1/wake", "", 204}, 98 | {"GET", "", "/api/v1/wake", `{"devices":[{"macAddress":"12:34:56:AB:CD:EF"},{"macAddress":"AB:CD:EF:12:34:56"}]}`, 200}, 99 | {"DELETE", `{"macAddress":"AB:CD:EF:12:34:56"}`, "/api/v1/wake", "", 204}, 100 | {"DELETE", `{"macAddress":"12:34:56:AB:CD:EF"}`, "/api/v1/wake", "", 204}, 101 | // Add device with name 102 | {"POST", `{"name":"foo","macAddress":"AB:CD:EF:12:34:56"}`, "/api/v1/wake", "", 204}, 103 | {"GET", "", "/api/v1/wake", `{"devices":[{"name":"foo","macAddress":"AB:CD:EF:12:34:56"}]}`, 200}, 104 | } 105 | 106 | for _, tt := range tests { 107 | var ( 108 | data string 109 | status int 110 | err error 111 | ) 112 | switch tt.method { 113 | case http.MethodGet: 114 | data, status, err = httpGet(server.URL + tt.url) 115 | case http.MethodPost: 116 | data, status, err = httpPost(server.URL+tt.url, tt.body) 117 | case http.MethodDelete: 118 | data, status, err = httpDelete(server.URL+tt.url, tt.body) 119 | default: 120 | t.Fatal("invalid method: " + tt.method) 121 | } 122 | if err != nil { 123 | t.Fatal(err) 124 | } 125 | if got := status; status != tt.status { 126 | t.Errorf("want status %d for %q, got %d", tt.status, tt.url, got) 127 | } 128 | if got := string(data); got != tt.response { 129 | t.Errorf("want response %q for %s, got %q", tt.response, tt.url, got) 130 | } 131 | } 132 | } 133 | -------------------------------------------------------------------------------- /static/.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "bitwise": false, 3 | "curly": true, 4 | "eqeqeq": true, 5 | "esversion": 6, 6 | "forin": true, 7 | "immed": true, 8 | "indent": 2, 9 | "latedef": true, 10 | "newcap": true, 11 | "noarg": true, 12 | "noempty": true, 13 | "nonew": true, 14 | "plusplus": true, 15 | "quotmark": "single", 16 | "undef": true, 17 | "unused": true, 18 | "trailing": true, 19 | "maxparams": 5, 20 | "maxdepth": 5, 21 | "maxstatements": 20, 22 | "maxcomplexity": 20, 23 | "maxlen": 120, 24 | "browser": true, 25 | "globals": { 26 | "m": true, 27 | "Mousetrap": true 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /static/app.js: -------------------------------------------------------------------------------- 1 | var wol = wol || {}; 2 | 3 | wol.state = { 4 | devices: [], 5 | toWake: { 6 | name: '', 7 | macAddress: '', 8 | setName: function(v) { 9 | wol.state.toWake.name = v; 10 | }, 11 | setMacAddress: function(v) { 12 | wol.state.toWake.macAddress = v; 13 | }, 14 | }, 15 | success: { 16 | timeout: null, 17 | device: {} 18 | }, 19 | error: {}, 20 | wake: function (device) { 21 | if (typeof device !== 'undefined') { 22 | wol.wakeDevice(device); 23 | } else { 24 | // Copy the toWake object here to avoid input values binding 25 | wol.wakeDevice({name: wol.state.toWake.name, 26 | macAddress: wol.state.toWake.macAddress}); 27 | } 28 | }, 29 | remove: function (device) { 30 | wol.removeDevice(device); 31 | }, 32 | add: function (device) { 33 | var exists = wol.state.devices.some(function (d) { 34 | return d.macAddress === device.macAddress; 35 | }); 36 | if (!exists) { 37 | wol.state.devices.push(device); 38 | wol.state.devices.sort(function (a, b) { 39 | if (a.macAddress < b.macAddress) { 40 | return -1; 41 | } 42 | if (a.macAddress > b.macAddress) { 43 | return 1; 44 | } 45 | return 0; 46 | }); 47 | } 48 | wol.state.toWake.setName(''); 49 | wol.state.toWake.setMacAddress(''); 50 | wol.state.error = {}; 51 | }, 52 | setSuccess: function (device) { 53 | wol.state.success.device = device; 54 | // Clear any pending timeout so that timeout is extended for each new 55 | // wake-up 56 | clearTimeout(wol.state.success.timeout); 57 | wol.state.success.timeout = setTimeout(function () { 58 | wol.state.success.device = {}; 59 | m.redraw(); 60 | }, 4000); 61 | } 62 | }; 63 | 64 | wol.getDevices = function() { 65 | m.request({method: 'GET', url: '/api/v1/wake'}) 66 | .then(function (data) { 67 | wol.state.devices = data.devices; 68 | return data; 69 | }, function (data) { 70 | wol.state.error = data; 71 | }); 72 | }; 73 | 74 | wol.wakeDevice = function(device) { 75 | m.request({method: 'POST', url: '/api/v1/wake', data: device}) 76 | .then(function (data) { 77 | wol.state.add(device); 78 | wol.state.setSuccess(device); 79 | return data; 80 | }, function (data) { 81 | wol.state.error = data; 82 | }); 83 | }; 84 | 85 | wol.removeDevice = function (device) { 86 | m.request({method: 'DELETE', url: '/api/v1/wake', data: device}) 87 | .then(function (data) { 88 | wol.state.devices = wol.state.devices.filter(function (d) { 89 | return d.macAddress !== device.macAddress; 90 | }); 91 | return data; 92 | }, function (data) { 93 | wol.state.error = data; 94 | }); 95 | }; 96 | 97 | wol.alertView = function () { 98 | var e = wol.state.error; 99 | var isError = Object.keys(e).length !== 0; 100 | var text = isError ? e.message + ' (' + e.status + ')' : ''; 101 | var cls = 'alert-danger' + (isError ? '' : ' hidden'); 102 | return m('div.alert', {class: cls}, [ 103 | m('span', {class: 'glyphicon glyphicon-exclamation-sign'}), 104 | m('strong', ' Error: '), text 105 | ]); 106 | }; 107 | 108 | wol.successView = function () { 109 | var device = wol.state.success.device; 110 | var isSuccess = Object.keys(device).length !== 0; 111 | var name = device.name ? ' (' + device.name + ')' : ''; 112 | var cls = 'alert-success' + (isSuccess ? '' : ' hidden'); 113 | return m('div.alert', {class: cls}, [ 114 | m('span', {class: 'glyphicon glyphicon-ok'}), 115 | ' Successfully woke ', m('strong', device.macAddress), name 116 | ]); 117 | }; 118 | 119 | wol.devicesView = function () { 120 | var form = m('form', { 121 | id: 'wake-form', 122 | onsubmit: function (e) { 123 | e.preventDefault(); 124 | wol.state.wake(); 125 | } 126 | }); 127 | var firstRow = m('tr', [ 128 | m('td', m('input[type=text]', {'form': form.attrs.id, 129 | onchange: m.withAttr('value', wol.state.toWake.setName), 130 | value: wol.state.toWake.name, 131 | class: 'form-control', 132 | placeholder: 'Name'})), 133 | m('td', m('input[type=text]', {'form': form.attrs.id, 134 | onchange: m.withAttr('value', wol.state.toWake.setMacAddress), 135 | value: wol.state.toWake.macAddress, 136 | class: 'form-control', 137 | placeholder: 'MAC address'})), 138 | m('td', m('button[type=submit]', 139 | {'form': form.attrs.id, class: 'btn btn-success btn-remove'}, 140 | m('span', {class: 'glyphicon glyphicon-off'}))), 141 | m('td') 142 | ]); 143 | var rows = wol.state.devices.map(function (device) { 144 | return m('tr', [ 145 | m('td', device.name || ''), 146 | m('td', m('code', device.macAddress)), 147 | m('td', 148 | m('button[type=button]', {class: 'btn btn-success btn-remove', 149 | onclick: function () { wol.state.wake(device); } }, 150 | m('span', {class: 'glyphicon glyphicon-off'}) 151 | ) 152 | ), 153 | m('td', 154 | m('button[type=button]', {class: 'btn btn-danger btn-remove', 155 | onclick: function () { wol.state.remove(device); } }, 156 | m('span', {class: 'glyphicon glyphicon-remove'}) 157 | ) 158 | ) 159 | ]); 160 | }); 161 | return [form, 162 | m('table.table', {class: ''}, 163 | m('thead', m('tr', [ 164 | m('th', {class: 'col-md-2'}, 'Device name'), 165 | m('th', {class: 'col-md-2'}, 'MAC address'), 166 | m('th', {class: 'col-md-1'}), 167 | m('th', {class: 'col-md-1'}) 168 | ])), 169 | m('tbody', [firstRow].concat(rows)) 170 | )]; 171 | }; 172 | 173 | wol.oncreate = wol.getDevices; 174 | 175 | wol.view = function() { 176 | return m('div.container', [ 177 | m('div.row', 178 | m('div.col-md-6', m('h1', m('span', {class: 'glyphicon glyphicon-flash'}), ' wake-on-lan')) 179 | ), 180 | m('div.row', m('div.col-md-6', wol.alertView())), 181 | m('div.row', m('div.col-md-6', wol.successView())), 182 | m('div.row', m('div.col-md-6', wol.devicesView())) 183 | ]); 184 | }; 185 | 186 | m.mount(document.getElementById('app'), wol); 187 | -------------------------------------------------------------------------------- /static/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | wake-on-lan 10 | 21 | 22 | 23 |
24 | 25 | 26 | 27 | 28 | -------------------------------------------------------------------------------- /static/screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/mpolden/wakeup/371d54b99019d509650024f261c670695a9efaf2/static/screenshot.png -------------------------------------------------------------------------------- /wol/bridge.go: -------------------------------------------------------------------------------- 1 | package wol 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "net" 8 | "sync" 9 | ) 10 | 11 | // Bridge represents a Wake-on-LAN bridge. 12 | type Bridge struct { 13 | conn io.ReadCloser 14 | lastSent MagicPacket 15 | wakeFunc func(net.IP, net.HardwareAddr) error 16 | mu sync.Mutex 17 | } 18 | 19 | // Listen listens for magic packets on the given addr. 20 | func Listen(addr string) (*Bridge, error) { 21 | udpAddr, err := net.ResolveUDPAddr("udp4", addr) 22 | if err != nil { 23 | return nil, err 24 | } 25 | conn, err := net.ListenUDP("udp4", udpAddr) 26 | if err != nil { 27 | return nil, err 28 | } 29 | return &Bridge{conn: conn, wakeFunc: Wake}, nil 30 | } 31 | 32 | // Close closes the connection. 33 | func Close(b *Bridge) error { return b.conn.Close() } 34 | 35 | // Forward reads a magic packet and writes it back to the network using src as the local address. 36 | func (b *Bridge) Forward(src net.IP) (MagicPacket, error) { 37 | b.mu.Lock() 38 | defer b.mu.Unlock() 39 | mp, err := b.read() 40 | if err != nil { 41 | return nil, err 42 | } 43 | // Do not resend if we just sent this packet 44 | if bytes.Equal(mp, b.lastSent) { 45 | b.lastSent = nil 46 | return nil, nil 47 | } 48 | if err := b.wakeFunc(src, mp.HardwareAddr()); err != nil { 49 | return nil, err 50 | } 51 | b.lastSent = mp 52 | return mp, nil 53 | } 54 | 55 | func (b *Bridge) read() (MagicPacket, error) { 56 | buf := make([]byte, 4096) 57 | n, err := b.conn.Read(buf) 58 | if err != nil { 59 | return nil, err 60 | } 61 | mp := buf[:n] 62 | if !IsMagicPacket(mp) { 63 | return nil, fmt.Errorf("invalid magic packet: %x", mp) 64 | } 65 | return mp, nil 66 | } 67 | -------------------------------------------------------------------------------- /wol/bridge_test.go: -------------------------------------------------------------------------------- 1 | package wol 2 | 3 | import ( 4 | "bytes" 5 | "io" 6 | "net" 7 | "testing" 8 | ) 9 | 10 | type mockConn struct{ io.Reader } 11 | 12 | func (c *mockConn) Close() error { return nil } 13 | 14 | func TestBridgeRead(t *testing.T) { 15 | b := Bridge{conn: &mockConn{bytes.NewReader(magicPacket)}} 16 | mp, err := b.read() 17 | if err != nil { 18 | t.Fatal(err) 19 | } 20 | want := "65:ac:81:13:8d:3f" 21 | if got := mp.HardwareAddr().String(); got != want { 22 | t.Errorf("want %s, got %s", want, got) 23 | } 24 | 25 | b.conn = &mockConn{bytes.NewReader([]byte{1, 2, 3})} 26 | want = "invalid magic packet: 010203" 27 | if _, err := b.read(); err.Error() != want { 28 | t.Errorf("got %q, want %q", err.Error(), want) 29 | } 30 | } 31 | 32 | func TestBridgeForward(t *testing.T) { 33 | var target net.HardwareAddr 34 | wake := func(src net.IP, hwAddr net.HardwareAddr) error { 35 | target = hwAddr 36 | return nil 37 | } 38 | b := Bridge{ 39 | conn: &mockConn{bytes.NewReader(magicPacket)}, 40 | wakeFunc: wake, 41 | } 42 | if _, err := b.Forward(nil); err != nil { 43 | t.Fatal(err) 44 | } 45 | want := "65:ac:81:13:8d:3f" 46 | if got := target.String(); got != want { 47 | t.Errorf("want %s, got %s", want, got) 48 | } 49 | } 50 | 51 | func TestBridgeForwardPreventsLoop(t *testing.T) { 52 | n := 0 53 | wake := func(src net.IP, hwAddr net.HardwareAddr) error { 54 | n += 1 55 | return nil 56 | } 57 | var buf bytes.Buffer 58 | b := Bridge{ 59 | conn: &mockConn{&buf}, 60 | wakeFunc: wake, 61 | } 62 | // Same magic packet is received a second time, this likely means that we sent it ourself 63 | for i := 0; i < 2; i++ { 64 | buf.Write(magicPacket) 65 | if _, err := b.Forward(nil); err != nil { 66 | t.Fatal(err) 67 | } 68 | } 69 | if n != 1 { 70 | t.Errorf("want 1 wake up, got %d", n) 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /wol/wol.go: -------------------------------------------------------------------------------- 1 | package wol 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "io" 7 | "net" 8 | ) 9 | 10 | const hwAddrN = 16 11 | 12 | var ( 13 | bcastAddr = []byte{255, 255, 255, 255, 255, 255} 14 | bcastAddrOff = len(bcastAddr) 15 | ) 16 | 17 | type MagicPacket []byte 18 | 19 | // HardwareAddr returns the physical address of the target computer. 20 | func (p MagicPacket) HardwareAddr() net.HardwareAddr { 21 | return net.HardwareAddr(p[bcastAddrOff : bcastAddrOff*2]) 22 | } 23 | 24 | // Create a magic packet for the given hwAddr. 25 | func NewMagicPacket(hwAddr net.HardwareAddr) MagicPacket { 26 | p := make([]byte, bcastAddrOff+(hwAddrN*len(hwAddr))) 27 | copy(p, bcastAddr) 28 | copy(p[bcastAddrOff:], bytes.Repeat(hwAddr, hwAddrN)) 29 | return p 30 | } 31 | 32 | // IsMagicPacket reports whether the byte array is a magic packet. 33 | func IsMagicPacket(b []byte) bool { 34 | if len(b) != 102 { 35 | return false 36 | } 37 | if !bytes.Equal(b[:6], bcastAddr) { 38 | return false 39 | } 40 | hwAddr := MagicPacket(b).HardwareAddr() 41 | return bytes.Equal(b[bcastAddrOff:], bytes.Repeat(hwAddr, hwAddrN)) 42 | } 43 | 44 | // Wake sends a magic packet for hwAddr to the broadcast address. If src is not nil, it is used as the local address for 45 | // the broadcast. 46 | func Wake(src net.IP, hwAddr net.HardwareAddr) error { 47 | var laddr *net.UDPAddr 48 | if src != nil { 49 | laddr = &net.UDPAddr{IP: src} 50 | } 51 | raddr := &net.UDPAddr{IP: net.IPv4bcast, Port: 9} 52 | conn, err := net.DialUDP("udp", laddr, raddr) 53 | if err != nil { 54 | return err 55 | } 56 | p := NewMagicPacket(hwAddr) 57 | n, err := conn.Write([]byte(p)) 58 | if err == nil && n < len(p) { 59 | return io.ErrShortWrite 60 | } 61 | if err1 := conn.Close(); err1 != nil { 62 | err = err1 63 | } 64 | return err 65 | } 66 | 67 | // WakeString sends a magic packet for macAddr to the broadcast address. If srcIP non-empty, it is used as the local 68 | // address for the broadcast. 69 | func WakeString(srcIP, macAddr string) error { 70 | hwAddr, err := net.ParseMAC(macAddr) 71 | if err != nil { 72 | return err 73 | } 74 | var src net.IP 75 | if srcIP != "" { 76 | src = net.ParseIP(srcIP) 77 | if src == nil { 78 | return fmt.Errorf("invalid ip: %s", srcIP) 79 | } 80 | } 81 | return Wake(src, hwAddr) 82 | } 83 | -------------------------------------------------------------------------------- /wol/wol_test.go: -------------------------------------------------------------------------------- 1 | package wol 2 | 3 | import ( 4 | "bytes" 5 | "net" 6 | "testing" 7 | ) 8 | 9 | var magicPacket = []byte{ 10 | 255, 255, 255, 255, 255, 255, 11 | 101, 172, 129, 19, 141, 63, 12 | 101, 172, 129, 19, 141, 63, 13 | 101, 172, 129, 19, 141, 63, 14 | 101, 172, 129, 19, 141, 63, 15 | 101, 172, 129, 19, 141, 63, 16 | 101, 172, 129, 19, 141, 63, 17 | 101, 172, 129, 19, 141, 63, 18 | 101, 172, 129, 19, 141, 63, 19 | 101, 172, 129, 19, 141, 63, 20 | 101, 172, 129, 19, 141, 63, 21 | 101, 172, 129, 19, 141, 63, 22 | 101, 172, 129, 19, 141, 63, 23 | 101, 172, 129, 19, 141, 63, 24 | 101, 172, 129, 19, 141, 63, 25 | 101, 172, 129, 19, 141, 63, 26 | 101, 172, 129, 19, 141, 63, 27 | } 28 | 29 | func TestNewMagicPacket(t *testing.T) { 30 | hwAddr, err := net.ParseMAC("65:ac:81:13:8d:3f") 31 | if err != nil { 32 | t.Fatal(err) 33 | } 34 | got := NewMagicPacket(hwAddr) 35 | if !bytes.Equal(got, magicPacket) { 36 | t.Errorf("want %v, got %v", magicPacket, got) 37 | } 38 | } 39 | 40 | func TestIsMagicPacket(t *testing.T) { 41 | var tests = []struct { 42 | in []byte 43 | out bool 44 | }{ 45 | {[]byte{}, false}, 46 | {[]byte{1, 2, 3}, false}, 47 | {magicPacket, true}, 48 | } 49 | for i, tt := range tests { 50 | if IsMagicPacket(tt.in) != tt.out { 51 | t.Errorf("#%d: want %t for %v, got %t", i, tt.out, tt.in, !tt.out) 52 | } 53 | } 54 | } 55 | --------------------------------------------------------------------------------