package main import ( // Standard "bytes" "crypto/tls" "encoding/base64" "encoding/json" "errors" "fmt" "io" "net/http" "time" ) type RouterosDevice struct { Host string Port int Username string Password string Timeout int httpClient *http.Client auth string added time.Time } type RouterosIDType int const ( IPv4 RouterosIDType = iota IPv6 ) type RouterosID struct { Type RouterosIDType ID string } func (dev *RouterosDevice) Init() { // {{{ cred := fmt.Sprintf("%s:%s", dev.Username, dev.Password) dev.auth = base64.StdEncoding.EncodeToString([]byte(cred)) tr := &http.Transport{ TLSClientConfig: &tls.Config{ InsecureSkipVerify: true, }, } client := http.Client{ Transport: tr, Timeout: time.Duration(dev.Timeout) * time.Second, } dev.httpClient = &client dev.added = time.Now() } // }}} // query sends a RouterOS REST API query and returns the unparsed body. func (dev RouterosDevice) query(method, path string, reqBody []byte) (body []byte, err error) { // {{{ url := fmt.Sprintf("https://%s:%d/rest%s", dev.Host, dev.Port, path) logger.Info("URL", "method", method, "url", url) var request *http.Request request, err = http.NewRequest(method, url, bytes.NewBuffer(reqBody)) if err != nil { return } request.Header.Add("Authorization", "Basic "+dev.auth) var response *http.Response response, err = dev.httpClient.Do(request) if err != nil { if response == nil { logger.Debug("REST call", "op", "request", "method", method, "path", path, "body", string(reqBody), "error", err) } else { logger.Debug("REST call", "op", "request", "method", method, "path", path, "body", string(reqBody), "code", response.StatusCode) } return } else { logger.Debug("REST call", "op", "request", "method", method, "path", path, "body", string(reqBody)) } body, _ = io.ReadAll(response.Body) if response.StatusCode < 200 || response.StatusCode >= 300 { err = fmt.Errorf("%s", body) logger.Debug("REST call", "op", "response", "method", method, "path", path, "code", response.StatusCode, "body", string(body)) return } logger.Debug("REST call", "op", "response", "method", method, "path", path, "code", response.StatusCode, "body", string(body)) return } // }}} func (dev *RouterosDevice) GetIdentity() (identity string, err error) { // {{{ var body []byte body, err = dev.query("GET", "/system/identity", []byte{}) name := struct{ Name string }{} err = json.Unmarshal(body, &name) if err != nil { return } identity = name.Name return } // }}} func (dev *RouterosDevice) StaticDNSEntries() (records []DNSRecord, err error) {// {{{ records = []DNSRecord{} var routerosEntries []DNSEntry var body []byte body, err = dev.query("GET", "/ip/dns/static", []byte{}) if err != nil { return } err = json.Unmarshal(body, &routerosEntries) if err != nil { return } for _, entry := range routerosEntries { records = append(records, NewDNSRecord(entry)) } logger.Info("FOO", "entry", records) return }// }}} func (dev *RouterosDevice) UpdateDNSEntry(record DNSEntry) (entry DNSEntry, err error) {// {{{ req, _ := json.Marshal(record) var body []byte if record.ID == "" { body, err = dev.query("PUT", "/ip/dns/static", req) } else { body, err = dev.query("PATCH", "/ip/dns/static/"+record.ID, req) } if err != nil { rosError := struct{ Detail string }{} if jsonError := json.Unmarshal([]byte(err.Error()), &rosError); jsonError == nil { logger.Error("routeros", "error", jsonError) err = errors.New(rosError.Detail) return } return } err = json.Unmarshal(body, &entry) return }// }}} func (dev *RouterosDevice) DeleteDNSEntry(id string) (err error) { _, err = dev.query("DELETE", "/ip/dns/static/"+id, []byte{}) if err != nil { rosError := struct{ Detail string }{} if jsonError := json.Unmarshal([]byte(err.Error()), &rosError); jsonError == nil { logger.Error("routeros", "error", jsonError) err = errors.New(rosError.Detail) return } return } return } /* // FillPeerDetails retrieves RouterOS resource ID, allowed-address and comment from the router func (dev *RouterosDevice) FillPeerDetails(peer *Peer) (err error) { // {{{ var body []byte // Retrieve the wireguard peer to get the IP address body, err = dev.query("GET", "/interface/wireguard/peers?public-key="+strings.TrimSpace(peer.PublicKey), []byte{}) if err != nil { return } devPeer := []struct { ID string `json:".id"` AllowedAddress string `json:"allowed-address"` Comment string }{} err = json.Unmarshal(body, &devPeer) if err != nil { return } if len(devPeer) != 1 { err = fmt.Errorf("Invalid number of peers returned (%d peers)", len(devPeer)) return } peer.routerosID = devPeer[0].ID peer.AllowedIPs = strings.Split(devPeer[0].AllowedAddress, ",") peer.Comment = devPeer[0].Comment var row *sqlx.Row row = service.Db.Conn.QueryRowx(` SELECT dev.config_template, p.id, p.user_id, p.routeros_device_id, p.public_key, p.description, _webservice.pgp_sym_decrypt( DECODE(p.private_key, 'base64'), $3, 'compress-algo=1, cipher-algo=aes256' ) AS private_key FROM public.peer p INNER JOIN routeros_device dev ON p.routeros_device_id = dev.id WHERE p.public_key = $1 OR p.id = $2 `, peer.PublicKey, peer.ID, config.Application.Secret, ) if err != nil { return } var dbPeer Peer err = row.StructScan(&dbPeer) if err != nil { return } peer.ConfigTemplate = dbPeer.ConfigTemplate peer.PrivateKey = dbPeer.PrivateKey peer.PublicKey = dbPeer.PublicKey return } // }}} func (dev *RouterosDevice) GetPeerStatus(peer *Peer, fillAllowedIPs bool) (allowedIPs []string, listID []RouterosID, exist bool, timeLeft int, err error) { // {{{ var body []byte // Retrieve the wireguard peer to get the IP address body, err = dev.query("GET", "/interface/wireguard/peers?public-key="+strings.TrimSpace(peer.PublicKey), []byte{}) if err != nil { return } devPeer := []struct { ID string `json:".id"` AllowedAddress string `json:"allowed-address"` Comment string }{} err = json.Unmarshal(body, &devPeer) if err != nil { return } if len(devPeer) != 1 { err = fmt.Errorf("Invalid number of peers returned (%d peers)", len(devPeer)) return } exist = true peer.routerosID = devPeer[0].ID // Finding if it is open or not prefix := regexp.MustCompile("(?i)([0-9.]+/32|[0-9a-f:]/128)$") rxpTime := regexp.MustCompile("(?:([0-9]+)d)?(?:([0-9]+)h)?(?:([0-9]+)m)?(?:([0-9]+)s)?") allowedAddresses := strings.Split(devPeer[0].AllowedAddress, ",") for _, ip := range allowedAddresses { if !prefix.Match([]byte(ip)) { logger.Debug("peer", "public-key", peer.PublicKey, "allowed-address", ip, "error", "Invalid address, must end in /30 or /128") continue } allowedIPs = append(allowedIPs, ip) ipParts := strings.Split(ip, "/") listIDEntry := RouterosID{} if len(ip) > 4 && ip[len(ip)-4:] == "/128" { listIDEntry.Type = IPv6 body, err = dev.query("GET", "/ipv6/firewall/address-list?list=Wireguard-Authenticated&address="+ip, []byte{}) } else if len(ip) > 3 && ip[len(ip)-3:] == "/32" { listIDEntry.Type = IPv4 body, err = dev.query("GET", "/ip/firewall/address-list?list=Wireguard-Authenticated&address="+ipParts[0], []byte{}) } else { err = fmt.Errorf("Invalid IP (%s)", ip) return } if err != nil { return } listEntry := []struct { ID string `json:".id"` Disabled string Timeout string }{} err = json.Unmarshal(body, &listEntry) if err != nil || len(listEntry) != 1 { continue } listIDEntry.ID = listEntry[0].ID listID = append(listID, listIDEntry) // Overwrite earlier timeout, as these should be the same value anyway. timeLeft = 0 time := rxpTime.FindStringSubmatch(listEntry[0].Timeout) if i, err := strconv.Atoi(time[1]); err == nil { timeLeft = i * 86400 } if i, err := strconv.Atoi(time[2]); err == nil { timeLeft += i * 3600 } if i, err := strconv.Atoi(time[3]); err == nil { timeLeft += i * 60 } if i, err := strconv.Atoi(time[4]); err == nil { timeLeft += i } } if fillAllowedIPs { peer.AllowedIPs = allowedIPs } return } // }}} func (dev *RouterosDevice) IsCachedIPInAddressList(peer *Peer) (inList bool, timeLeft int, err error) { // {{{ var body []byte // Finding if it is open or not prefix := regexp.MustCompile("(?i)([0-9.]+/32|[0-9a-f:]/128)$") rxpTime := regexp.MustCompile("(?:([0-9]+)d)?(?:([0-9]+)h)?(?:([0-9]+)m)?(?:([0-9]+)s)?") allowedAddresses := peer.CachedAddresses for _, ip := range allowedAddresses { // This indicates existence of key and address in GetPeerStatus, // but can't here since it is too expensive to check device wireguard peers. inList = true if !prefix.Match([]byte(ip)) { logger.Debug("peer", "public-key", peer.PublicKey, "allowed-address", ip, "error", "Invalid address, must end in /30 or /128") continue } ipParts := strings.Split(ip, "/") listIDEntry := RouterosID{} if len(ip) > 4 && ip[len(ip)-4:] == "/128" { listIDEntry.Type = IPv6 body, err = dev.query("GET", "/ipv6/firewall/address-list?list=Wireguard-Authenticated&address="+ip, []byte{}) } else if len(ip) > 3 && ip[len(ip)-3:] == "/32" { listIDEntry.Type = IPv4 body, err = dev.query("GET", "/ip/firewall/address-list?list=Wireguard-Authenticated&address="+ipParts[0], []byte{}) } else { err = fmt.Errorf("Invalid IP (%s)", ip) return } if err != nil { return } listEntry := []struct { ID string `json:".id"` Disabled string Timeout string }{} err = json.Unmarshal(body, &listEntry) if err != nil || len(listEntry) != 1 { continue } listIDEntry.ID = listEntry[0].ID // Overwrite earlier timeout, as these should be the same value anyway. timeLeft = 0 time := rxpTime.FindStringSubmatch(listEntry[0].Timeout) if i, err := strconv.Atoi(time[1]); err == nil { timeLeft = i * 86400 } if i, err := strconv.Atoi(time[2]); err == nil { timeLeft += i * 3600 } if i, err := strconv.Atoi(time[3]); err == nil { timeLeft += i * 60 } if i, err := strconv.Atoi(time[4]); err == nil { timeLeft += i } } return } // }}} func (dev *RouterosDevice) ClosePeer(sess *session.T, peer Peer) (err error) { // {{{ var body []byte var listID []RouterosID for _, ip := range peer.CachedAddresses { ipParts := strings.Split(ip, "/") listIDEntry := RouterosID{} if len(ip) > 4 && ip[len(ip)-4:] == "/128" { listIDEntry.Type = IPv6 body, err = dev.query("GET", "/ipv6/firewall/address-list?list=Wireguard-Authenticated&address="+ip, []byte{}) } else if len(ip) > 3 && ip[len(ip)-3:] == "/32" { listIDEntry.Type = IPv4 body, err = dev.query("GET", "/ip/firewall/address-list?list=Wireguard-Authenticated&address="+ipParts[0], []byte{}) } else { err = fmt.Errorf("Invalid IP (%s)", ip) return } if err != nil { return } listEntry := []struct { ID string `json:".id"` Disabled string Timeout string }{} err = json.Unmarshal(body, &listEntry) if err != nil || len(listEntry) != 1 { continue } listIDEntry.ID = listEntry[0].ID listID = append(listID, listIDEntry) } for _, listID := range listID { switch listID.Type { case IPv4: body, err = dev.query("DELETE", "/ip/firewall/address-list/"+listID.ID, []byte{}) case IPv6: body, err = dev.query("DELETE", "/ipv6/firewall/address-list/"+listID.ID, []byte{}) } if err != nil { return } if string(body) != "" { devError := struct { Error int Message string Detail string }{} err = json.Unmarshal(body, &devError) if err != nil { return } if devError.Message != "" { err = fmt.Errorf("%s", devError.Message) return } } } return } // }}} func (dev *RouterosDevice) OpenPeer(sess *session.T, peer Peer) (err error) { // {{{ allowedIPs := peer.CachedAddresses var body []byte for _, ip := range allowedIPs { req, _ := json.Marshal(struct { List string `json:"list"` Address string `json:"address"` Timeout string `json:"timeout"` Comment string `json:"comment"` }{ "Wireguard-Authenticated", ip, "08:00:00", sess.Username + ", " + peer.Description, }, ) if len(ip) > 4 && ip[len(ip)-4:] == "/128" { body, err = dev.query("PUT", "/ipv6/firewall/address-list", req) } else if len(ip) > 3 && ip[len(ip)-3:] == "/32" { body, err = dev.query("PUT", "/ip/firewall/address-list", req) } else { err = fmt.Errorf("Invalid IP (%s)", ip) return } if err != nil { return } devError := struct { Error int Message string Detail string }{} err = json.Unmarshal(body, &devError) if err != nil { return } if devError.Message != "" { err = fmt.Errorf("%s", devError.Message) return } } return } // }}} func (dev *RouterosDevice) GetPeers() (err error) { // {{{ dev.Peers = []*Peer{} var rows *sqlx.Rows rows, err = service.Db.Conn.Queryx( ` SELECT p.id, p.user_id, p.routeros_device_id, p.description, p.public_key, _webservice.pgp_sym_decrypt( DECODE(p.private_key, 'base64'), $2, 'compress-algo=1, cipher-algo=aes256' ) AS private_key, cached_addresses FROM public.peer p INNER JOIN _webservice.user u ON p.user_id = u.id WHERE routeros_device_id = $1 ORDER BY u.name ASC, p.description ASC `, dev.ID, config.Application.Secret, ) if err != nil { return } defer rows.Close() for rows.Next() { var peer Peer if err = rows.StructScan(&peer); err != nil { return } dev.Peers = append(dev.Peers, &peer) } return } // }}} */ /* func (dev *RouterosDevice) UpdatePeer(peer Peer) (err error) { // {{{ allowedIPs := peer.AllowedIPs _, err = service.Db.Conn.Exec( `UPDATE public.peer SET description = $2, user_id = $3, cached_addresses = $4 WHERE id = $1`, peer.ID, peer.Description, peer.UserID, peer.CachedAddresses, ) if err != nil { return } err = dev.FillPeerDetails(&peer) if err != nil { return } // This isn't necessarily set if allowedIPs == nil { return } req := struct { AllowedAddress string `json:"allowed-address"` }{ strings.Join(allowedIPs, ","), } reqJSON, _ := json.Marshal(req) _, err = dev.query("PATCH", "/interface/wireguard/peers/"+peer.routerosID, reqJSON) if err != nil { return } return } // }}} func (dev *RouterosDevice) CreatePeer(peer Peer) (err error) { // {{{ req := struct { Interface string `json:"interface"` PublicKey string `json:"public-key"` AllowedAddresses string `json:"allowed-address"` Comment string `json:"comment"` }{ dev.WireguardInterface, peer.PublicKey, strings.Join(peer.AllowedIPs, ","), peer.Comment, } reqJSON, _ := json.Marshal(req) _, err = dev.query("PUT", "/interface/wireguard/peers", reqJSON) return } // }}} func (dev *RouterosDevice) DeletePeer(peer Peer) (err error) { // {{{ if err = dev.FillPeerDetails(&peer); err != nil { return } _, err = dev.query("DELETE", "/interface/wireguard/peers/"+peer.routerosID, []byte{}) return } // }}} func (dev *RouterosDevice) AllowedIPs() (peers []*PeerIPs, err error) { // {{{ var body []byte body, err = dev.query("GET", "/interface/wireguard/peers?interface="+dev.WireguardInterface, nil) if err != nil { return } err = json.Unmarshal(body, &peers) if err != nil { return } for _, peer := range peers { peer.AllowedIPs = strings.Split(peer.AllowedAddress, ",") } return } // }}} */