├── version └── ver.go ├── .gitignore ├── api ├── message.go ├── peer_list_units.go ├── unit_show.go ├── peer_report.go ├── cors.go ├── peer_data.go ├── units_list.go ├── setup.go ├── unit_enroll.go ├── setup_server.go ├── setup_peer.go ├── utils.go ├── auth.go ├── unit_report.go ├── peer_mesh.go ├── peer_inbox.go ├── unit_inbox.go └── client.go ├── env.example ├── models ├── access_point.go ├── unit_inbox.go ├── setup.go ├── unit_enroll.go ├── message.go ├── unit_find.go ├── enrollment.go └── unit.go ├── pwngrid-peer.service ├── pwngrid.service ├── utils ├── host.go └── exec.go ├── wifi ├── parse.go ├── defines.go ├── unpack.go ├── utils.go ├── compression.go └── pack.go ├── .github └── FUNDING.yml ├── cmd └── pwngrid │ ├── main.go │ ├── vars.go │ ├── inbox.go │ └── setup.go ├── Dockerfile ├── go.mod ├── release.sh ├── README.md ├── mesh ├── hopping.go ├── interface.go ├── peer_json.go ├── memory.go ├── packet_muxer.go ├── routing.go └── peer.go ├── crypto ├── sign.go ├── encrypt.go └── keypair.go ├── docker-compose.yml ├── changelog.sh ├── .travis.yml ├── Makefile ├── go.sum └── LICENSE.md /version/ver.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | const ( 4 | Version = "1.11.4" 5 | ) 6 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .env 2 | .idea 3 | test-unit 4 | test-unit.pub 5 | build 6 | id_rsa 7 | id_rsa.pub 8 | key.json 9 | -------------------------------------------------------------------------------- /api/message.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | type Message struct { 4 | Data string `json:"data"` 5 | Signature string `json:"signature"` 6 | } 7 | -------------------------------------------------------------------------------- /env.example: -------------------------------------------------------------------------------- 1 | API_SECRET=02zygnJs5e0bBLJjaHCinWTjfRdheTYO 2 | 3 | DB_HOST=pwngrid-mysql 4 | DB_DRIVER=mysql 5 | DB_USER=pwngrid 6 | DB_PASSWORD=pwngrid 7 | DB_NAME=pwngrid 8 | DB_PORT=3306 -------------------------------------------------------------------------------- /models/access_point.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import "github.com/jinzhu/gorm" 4 | 5 | type AccessPoint struct { 6 | gorm.Model 7 | 8 | UnitID uint `json:"-"` 9 | Name string `gorm:"size:255;not null" json:"name"` 10 | Mac string `gorm:"size:255;not null" json:"mac"` 11 | } 12 | -------------------------------------------------------------------------------- /pwngrid-peer.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=pwngrid peer service 3 | Documentation=https://pwnagotchi.org/ 4 | Wants=network.target 5 | After=network.target 6 | 7 | [Service] 8 | Type=simple 9 | ExecStart=/usr/local/bin/pwngrid -log /var/log/pwngrid.log -peers /root/peers -address 127.0.0.1:8666 10 | Restart=always 11 | RestartSec=30 12 | 13 | [Install] 14 | WantedBy=multi-user.target 15 | -------------------------------------------------------------------------------- /pwngrid.service: -------------------------------------------------------------------------------- 1 | [Unit] 2 | Description=pwngrid api service 3 | Documentation=https://pwnagotchi.org/ 4 | Wants=network.target 5 | After=network.target 6 | 7 | [Service] 8 | Type=simple 9 | ExecStart=/usr/local/bin/pwngrid -log /var/log/pwngrid.log -env /etc/pwngrid/pwngrid.conf -address 127.0.0.1:8666 10 | Restart=always 11 | RestartSec=30 12 | 13 | [Install] 14 | WantedBy=multi-user.target -------------------------------------------------------------------------------- /utils/host.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/evilsocket/islazy/log" 5 | "os" 6 | "strings" 7 | ) 8 | 9 | func Hostname() string { 10 | name, err := os.Hostname() 11 | if err != nil { 12 | log.Warning("%v", err) 13 | return "" 14 | } 15 | 16 | if strings.HasSuffix(name, ".local") { 17 | name = strings.Replace(name, ".local", "", -1) 18 | } 19 | 20 | return name 21 | } 22 | -------------------------------------------------------------------------------- /utils/exec.go: -------------------------------------------------------------------------------- 1 | package utils 2 | 3 | import ( 4 | "github.com/evilsocket/islazy/str" 5 | "os/exec" 6 | ) 7 | 8 | func Exec(executable string, args []string) (string, error) { 9 | path, err := exec.LookPath(executable) 10 | if err != nil { 11 | return "", err 12 | } 13 | 14 | raw, err := exec.Command(path, args...).CombinedOutput() 15 | if err != nil { 16 | return "", err 17 | } else { 18 | return str.Trim(string(raw)), nil 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /api/peer_list_units.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | ) 6 | 7 | func (api *API) PeerListUnits(w http.ResponseWriter, r *http.Request) { 8 | page, err := pageNum(r) 9 | if err != nil { 10 | ERROR(w, http.StatusUnprocessableEntity, err) 11 | return 12 | } 13 | 14 | obj, err := api.Client.PagedUnits(page) 15 | if err != nil { 16 | ERROR(w, http.StatusUnprocessableEntity, err) 17 | return 18 | } 19 | 20 | JSON(w, http.StatusOK, obj) 21 | } 22 | -------------------------------------------------------------------------------- /api/unit_show.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/go-chi/chi/v5" 5 | "github.com/jayofelony/pwngrid/models" 6 | "net/http" 7 | ) 8 | 9 | func (api *API) ShowUnit(w http.ResponseWriter, r *http.Request) { 10 | unitFingerprint := chi.URLParam(r, "fingerprint") 11 | if unit := models.FindUnitByFingerprint(unitFingerprint); unit == nil { 12 | ERROR(w, http.StatusNotFound, ErrEmpty) 13 | return 14 | } else { 15 | JSON(w, http.StatusOK, unit) 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /wifi/parse.go: -------------------------------------------------------------------------------- 1 | package wifi 2 | 3 | import ( 4 | "github.com/gopacket/gopacket" 5 | "github.com/gopacket/gopacket/layers" 6 | ) 7 | 8 | func Parse(packet gopacket.Packet) (ok bool, radio *layers.RadioTap, dot11 *layers.Dot11) { 9 | ok = false 10 | radio = nil 11 | dot11 = nil 12 | 13 | radioLayer := packet.Layer(layers.LayerTypeRadioTap) 14 | if radioLayer == nil { 15 | return 16 | } 17 | radio, ok = radioLayer.(*layers.RadioTap) 18 | if !ok || radio == nil { 19 | return 20 | } 21 | 22 | dot11Layer := packet.Layer(layers.LayerTypeDot11) 23 | if dot11Layer == nil { 24 | ok = false 25 | return 26 | } 27 | 28 | dot11, ok = dot11Layer.(*layers.Dot11) 29 | return 30 | } 31 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: jayofelony # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | otechie: # Replace with a single Otechie username 12 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 13 | -------------------------------------------------------------------------------- /cmd/pwngrid/main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | "fmt" 6 | "github.com/evilsocket/islazy/log" 7 | "github.com/jayofelony/pwngrid/version" 8 | ) 9 | 10 | func main() { 11 | flag.Parse() 12 | 13 | setupCore() 14 | defer cleanup() 15 | 16 | // just print the version and exit 17 | if ver { 18 | fmt.Println(version.Version) 19 | return 20 | } 21 | 22 | // from here on we need logging 23 | if err := log.Open(); err != nil { 24 | panic(err) 25 | } 26 | defer log.Close() 27 | 28 | // do mode related initialization 29 | setupMode() 30 | 31 | // if we're in peer mode and is an inbox action 32 | if inbox { 33 | inboxMain() 34 | } else { 35 | // just start the API in either modes 36 | server.Run(address) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /Dockerfile: -------------------------------------------------------------------------------- 1 | FROM golang:alpine as builder 2 | 3 | # ENV GO111MODULE=on 4 | 5 | LABEL maintainer="Simone Margaritelli " 6 | 7 | RUN apk update && apk add --no-cache git 8 | 9 | # download, cache and install deps 10 | WORKDIR /app 11 | COPY go.mod go.sum ./ 12 | RUN go mod download 13 | 14 | # copy and compiled the app 15 | COPY . . 16 | RUN CGO_ENABLED=0 GOOS=linux go build -a -installsuffix cgo -o pwngrid cmd/pwngrid/main.go 17 | 18 | # start a new stage from scratch 19 | FROM alpine:latest 20 | RUN apk --no-cache add ca-certificates 21 | 22 | WORKDIR /root/ 23 | 24 | # copy the prebuilt binary and .env from the builder stage 25 | COPY --from=builder /app/pwngrid . 26 | COPY --from=builder /app/.env . 27 | 28 | EXPOSE 8666 29 | 30 | CMD ["./pwngrid"] -------------------------------------------------------------------------------- /api/peer_report.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/evilsocket/islazy/log" 6 | "io" 7 | "net/http" 8 | ) 9 | 10 | // PeerReportAP POST /api/v1/report/ap 11 | func (api *API) PeerReportAP(w http.ResponseWriter, r *http.Request) { 12 | var report apReport 13 | 14 | body, err := io.ReadAll(r.Body) 15 | if err != nil { 16 | ERROR(w, http.StatusUnprocessableEntity, err) 17 | return 18 | } 19 | 20 | log.Debug("%s", body) 21 | 22 | if err = json.Unmarshal(body, &report); err != nil { 23 | ERROR(w, http.StatusUnprocessableEntity, err) 24 | return 25 | } 26 | 27 | obj, err := api.Client.ReportAP(report) 28 | if err != nil { 29 | ERROR(w, http.StatusUnprocessableEntity, err) 30 | return 31 | } 32 | 33 | JSON(w, http.StatusOK, obj) 34 | } 35 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/jayofelony/pwngrid 2 | 3 | go 1.25 4 | 5 | require ( 6 | github.com/biezhi/gorm-paginator/pagination v0.0.0-20250219022659-7f61c90f8f21 7 | github.com/evilsocket/islazy v1.11.0 8 | github.com/go-chi/chi/v5 v5.2.3 9 | github.com/go-chi/cors v1.2.2 10 | github.com/golang-jwt/jwt/v5 v5.3.0 11 | github.com/gopacket/gopacket v1.5.0 12 | github.com/jinzhu/gorm v1.9.16 13 | github.com/joho/godotenv v1.5.1 14 | ) 15 | 16 | require ( 17 | filippo.io/edwards25519 v1.1.0 // indirect 18 | github.com/go-sql-driver/mysql v1.9.3 // indirect 19 | github.com/jinzhu/inflection v1.0.0 // indirect 20 | github.com/jinzhu/now v1.1.5 // indirect 21 | golang.org/x/sys v0.39.0 // indirect 22 | golang.org/x/text v0.32.0 // indirect 23 | gorm.io/gorm v1.31.1 // indirect 24 | ) 25 | -------------------------------------------------------------------------------- /api/cors.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/go-chi/cors" 5 | "net/http" 6 | ) 7 | 8 | func CORS(next http.Handler) http.Handler { 9 | cors := cors.New(cors.Options{ 10 | AllowedOrigins: []string{"*"}, 11 | AllowedMethods: []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"}, 12 | AllowedHeaders: []string{"Accept", "Content-Type", "Content-Length", "Accept-Encoding", "X-CSRF-Token", "Authorization"}, 13 | AllowCredentials: true, 14 | MaxAge: 300, 15 | }) 16 | return cors.Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 17 | w.Header().Add("X-Frame-Options", "DENY") 18 | w.Header().Add("X-Content-Type-Options", "nosniff") 19 | w.Header().Add("X-XSS-Protection", "1; mode=block") 20 | w.Header().Add("Referrer-Policy", "same-origin") 21 | next.ServeHTTP(w, r) 22 | })) 23 | } 24 | -------------------------------------------------------------------------------- /api/peer_data.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/evilsocket/islazy/log" 6 | "io" 7 | "net/http" 8 | ) 9 | 10 | // PeerGetData GET /api/v1/data 11 | func (api *API) PeerGetData(w http.ResponseWriter, r *http.Request) { 12 | JSON(w, http.StatusOK, api.Client.Data()) 13 | } 14 | 15 | // PeerSetData POST /api/v1/data 16 | func (api *API) PeerSetData(w http.ResponseWriter, r *http.Request) { 17 | var newData map[string]interface{} 18 | 19 | body, err := io.ReadAll(r.Body) 20 | if err != nil { 21 | ERROR(w, http.StatusUnprocessableEntity, err) 22 | return 23 | } 24 | 25 | log.Debug("%s", body) 26 | 27 | if err = json.Unmarshal(body, &newData); err != nil { 28 | ERROR(w, http.StatusUnprocessableEntity, err) 29 | return 30 | } 31 | 32 | JSON(w, http.StatusOK, api.Client.SetData(newData)) 33 | } 34 | -------------------------------------------------------------------------------- /wifi/defines.go: -------------------------------------------------------------------------------- 1 | package wifi 2 | 3 | import ( 4 | "github.com/gopacket/gopacket" 5 | "github.com/gopacket/gopacket/layers" 6 | "net" 7 | ) 8 | 9 | const ( 10 | IDWhisperPayload layers.Dot11InformationElementID = 222 11 | IDWhisperCompression layers.Dot11InformationElementID = 223 12 | IDWhisperIdentity layers.Dot11InformationElementID = 224 13 | IDWhisperSignature layers.Dot11InformationElementID = 225 14 | IDWhisperStreamHeader layers.Dot11InformationElementID = 226 15 | ) 16 | 17 | var SerializationOptions = gopacket.SerializeOptions{ 18 | FixLengths: true, 19 | ComputeChecksums: true, 20 | } 21 | 22 | var ( 23 | SignatureAddr = net.HardwareAddr{0xde, 0xad, 0xbe, 0xef, 0xde, 0xad} 24 | SignatureAddrStr = "de:ad:be:ef:de:ad" 25 | BroadcastAddr = net.HardwareAddr{0xff, 0xff, 0xff, 0xff, 0xff, 0xff} 26 | wpaFlags = 1041 27 | ) 28 | -------------------------------------------------------------------------------- /api/units_list.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/evilsocket/islazy/log" 5 | "github.com/jayofelony/pwngrid/models" 6 | "net/http" 7 | ) 8 | 9 | func (api *API) ListUnits(w http.ResponseWriter, r *http.Request) { 10 | page, err := pageNum(r) 11 | if err != nil { 12 | ERROR(w, http.StatusUnprocessableEntity, err) 13 | return 14 | } 15 | 16 | units, total, pages := models.GetPagedUnits(page) 17 | 18 | JSON(w, http.StatusOK, map[string]interface{}{ 19 | "records": total, 20 | "pages": pages, 21 | "units": units, 22 | }) 23 | } 24 | 25 | func (api *API) UnitsByCountry(w http.ResponseWriter, r *http.Request) { 26 | if results, err := models.GetUnitsByCountry(); err != nil { 27 | log.Warning("error getting units by country: %v", err) 28 | ERROR(w, http.StatusInternalServerError, err) 29 | return 30 | } else { 31 | JSON(w, http.StatusOK, results) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /models/unit_inbox.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | func (u *Unit) GetPagedInbox(page int) (messages []Message, total int, pages int) { 4 | const limit = 50 5 | if page < 1 { 6 | page = 1 7 | } 8 | 9 | query := db.Model(&Message{}).Where("receiver_id = ?", u.ID) 10 | 11 | var total64 int64 12 | if err := query.Count(&total64).Error; err != nil { 13 | return nil, 0, 0 14 | } 15 | total = int(total64) 16 | if total == 0 { 17 | return []Message{}, 0, 0 18 | } 19 | 20 | offset := (page - 1) * limit 21 | if err := query.Order("id desc").Limit(limit).Offset(offset).Find(&messages).Error; err != nil { 22 | return nil, 0, 0 23 | } 24 | 25 | pages = (total + limit - 1) / limit 26 | return messages, total, pages 27 | } 28 | 29 | func (u *Unit) GetInboxMessage(id int) *Message { 30 | var msg Message 31 | if err := db.Where("receiver_id = ? AND id = ?", u.ID, id).First(&msg).Error; err != nil { 32 | return nil 33 | } 34 | return &msg 35 | } 36 | -------------------------------------------------------------------------------- /wifi/unpack.go: -------------------------------------------------------------------------------- 1 | package wifi 2 | 3 | import ( 4 | "fmt" 5 | "github.com/gopacket/gopacket" 6 | "github.com/gopacket/gopacket/layers" 7 | ) 8 | 9 | func Unpack(pkt gopacket.Packet, radio *layers.RadioTap, dot11 *layers.Dot11) (error, []byte) { 10 | compressed := false 11 | payload := make([]byte, 0) 12 | 13 | for _, layer := range pkt.Layers() { 14 | if layer.LayerType() == layers.LayerTypeDot11InformationElement { 15 | if info, ok := layer.(*layers.Dot11InformationElement); ok { 16 | if info.ID == IDWhisperPayload { 17 | payload = append(payload, info.Info...) 18 | } else if info.ID == IDWhisperCompression { 19 | compressed = true 20 | } 21 | } 22 | } 23 | } 24 | 25 | if compressed { 26 | if decompressed, err := Decompress(payload); err != nil { 27 | return fmt.Errorf("error decompressing payload: %v", err), nil 28 | } else { 29 | payload = decompressed 30 | } 31 | } 32 | 33 | return nil, payload 34 | } 35 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | # nothing to see here, just a utility i use to create new releases ^_^ 3 | 4 | VERSION_FILE=$(dirname "${BASH_SOURCE[0]}")/version/ver.go 5 | echo "version file is $VERSION_FILE" 6 | CURRENT_VERSION=$(cat "$VERSION_FILE" | grep Version | cut -d '"' -f 2) 7 | TO_UPDATE=( 8 | # shellcheck disable=SC2206 9 | $VERSION_FILE 10 | ) 11 | 12 | echo -n "current version is $CURRENT_VERSION, select new version: " 13 | read NEW_VERSION 14 | # shellcheck disable=SC2028 15 | echo "creating version $NEW_VERSION ...\n" 16 | 17 | for file in "${TO_UPDATE[@]}"; do 18 | echo "patching $file ..." 19 | sed -i.bak "s/$CURRENT_VERSION/$NEW_VERSION/g" "$file" 20 | rm -rf "$file.bak" 21 | git add "$file" 22 | done 23 | 24 | git commit -m "releasing v$NEW_VERSION" 25 | git push 26 | git tag -a v"$NEW_VERSION" -m "release v$NEW_VERSION" 27 | # shellcheck disable=SC2086 28 | git push origin v$NEW_VERSION 29 | 30 | echo 31 | echo "All done, v$NEW_VERSION released ^_^" -------------------------------------------------------------------------------- /models/setup.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "github.com/evilsocket/islazy/log" 6 | "github.com/jinzhu/gorm" 7 | "os" 8 | ) 9 | 10 | var db *gorm.DB 11 | 12 | func Setup() (err error) { 13 | hostname := os.Getenv("DB_HOST") 14 | port := os.Getenv("DB_PORT") 15 | username := os.Getenv("DB_USER") 16 | password := os.Getenv("DB_PASSWORD") 17 | name := os.Getenv("DB_NAME") 18 | 19 | log.Info("connecting to %s:%s ...", hostname, port) 20 | dbURL := fmt.Sprintf("%s:%s@tcp(%s:%s)/%s?charset=utf8mb4&parseTime=True&loc=Local", username, password, hostname, port, name) 21 | if db, err = gorm.Open("mysql", dbURL); err != nil { 22 | return 23 | } 24 | db.Debug().AutoMigrate(&Unit{}, &AccessPoint{}, &Message{}) 25 | return 26 | } 27 | 28 | func Create(v interface{}) *gorm.DB { 29 | return db.Create(v) 30 | } 31 | 32 | func Update(v interface{}) *gorm.DB { 33 | return db.Model(v).Update(v) 34 | } 35 | 36 | func UpdateFields(v interface{}, fields map[string]interface{}) *gorm.DB { 37 | return db.Model(v).Updates(fields) 38 | } 39 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PwnGRID 2 | 3 |

4 |

5 | Release 6 | Software License 7 | Contributors 8 | Travis 9 | follow on Twitter 10 |

11 |

12 | 13 | This is the source code of the API server for https://pwnagotchi.ai/ 14 | -------------------------------------------------------------------------------- /wifi/utils.go: -------------------------------------------------------------------------------- 1 | package wifi 2 | 3 | import ( 4 | "bytes" 5 | "github.com/gopacket/gopacket" 6 | "github.com/gopacket/gopacket/layers" 7 | ) 8 | 9 | func Serialize(layers ...gopacket.SerializableLayer) (error, []byte) { 10 | buf := gopacket.NewSerializeBuffer() 11 | if err := gopacket.SerializeLayers(buf, SerializationOptions, layers...); err != nil { 12 | return err, nil 13 | } 14 | return nil, buf.Bytes() 15 | } 16 | 17 | func IsBroadcast(dot11 *layers.Dot11) bool { 18 | return bytes.Equal(dot11.Address1, BroadcastAddr) 19 | } 20 | 21 | func Freq2Chan(freq int) int { 22 | if freq <= 2472 { 23 | return ((freq - 2412) / 5) + 1 24 | } else if freq == 2484 { 25 | return 14 26 | } else if freq >= 5035 && freq <= 5885 { 27 | return ((freq - 5035) / 5) + 7 28 | } 29 | return 0 30 | } 31 | 32 | func Chan2Freq(channel int) int { 33 | if channel <= 13 { 34 | return ((channel - 1) * 5) + 2412 35 | } else if channel == 14 { 36 | return 2484 37 | } else if channel <= 177 { 38 | return ((channel - 7) * 5) + 5035 39 | } 40 | return 0 41 | } 42 | -------------------------------------------------------------------------------- /api/setup.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "net/http" 5 | 6 | "github.com/go-chi/chi/v5" 7 | "github.com/jayofelony/pwngrid/crypto" 8 | "github.com/jayofelony/pwngrid/mesh" 9 | 10 | _ "github.com/jinzhu/gorm/dialects/mysql" 11 | 12 | "github.com/evilsocket/islazy/log" 13 | ) 14 | 15 | type API struct { 16 | Router *chi.Mux 17 | Keys *crypto.KeyPair 18 | Peer *mesh.Peer 19 | Mesh *mesh.Router 20 | Client *Client 21 | } 22 | 23 | func Setup(keys *crypto.KeyPair, peer *mesh.Peer, router *mesh.Router, Endpoint string, Hostname string) (err error, api *API) { 24 | api = &API{ 25 | Router: chi.NewRouter(), 26 | Keys: keys, 27 | Peer: peer, 28 | Mesh: router, 29 | Client: NewClient(keys, Endpoint, Hostname), 30 | } 31 | 32 | api.Router.Use(CORS) 33 | if api.Keys == nil { 34 | api.setupServerRoutes() 35 | } else { 36 | api.setupPeerRoutes() 37 | } 38 | 39 | return 40 | } 41 | 42 | func (api *API) Run(addr string) { 43 | log.Info("pwngrid api starting on %s ...", addr) 44 | log.Fatal("%v", http.ListenAndServe(addr, api.Router)) 45 | } 46 | -------------------------------------------------------------------------------- /mesh/hopping.go: -------------------------------------------------------------------------------- 1 | package mesh 2 | 3 | import ( 4 | "github.com/evilsocket/islazy/log" 5 | "github.com/evilsocket/islazy/str" 6 | "sort" 7 | "strconv" 8 | "time" 9 | ) 10 | 11 | func ChannelHopping(iface string, chanList string, allChannels []int, hopPeriod int) { 12 | var channels []int 13 | for _, s := range str.Comma(chanList) { 14 | if ch, err := strconv.Atoi(s); err != nil { 15 | log.Fatal("%v", err) 16 | } else { 17 | channels = append(channels, ch) 18 | } 19 | } 20 | if len(channels) == 0 { 21 | channels = allChannels 22 | } 23 | sort.Ints(channels) 24 | 25 | go func() { 26 | period := time.Duration(hopPeriod) * time.Millisecond 27 | tick := time.NewTicker(period) 28 | 29 | log.Info("channel hopper started (period:%s channels:%v)", period, channels) 30 | 31 | loop := 0 32 | for _ = range tick.C { 33 | ch := channels[loop%len(channels)] 34 | // log.Debug("hopping on channel %d", ch) 35 | if err, out := SetChannel(iface, ch); err != nil { 36 | log.Error("%v: %s", err, out) 37 | } 38 | loop++ 39 | } 40 | }() 41 | } 42 | -------------------------------------------------------------------------------- /crypto/sign.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto" 5 | "crypto/rand" 6 | "crypto/rsa" 7 | ) 8 | 9 | var pssOpts = rsa.PSSOptions{ 10 | SaltLength: 16, 11 | } 12 | 13 | const Hasher = crypto.SHA256 14 | 15 | func (pair *KeyPair) Sign(hash crypto.Hash, hashed []byte) ([]byte, error) { 16 | return rsa.SignPSS(rand.Reader, pair.Private, hash, hashed, &pssOpts) 17 | } 18 | 19 | func (pair *KeyPair) SignMessage(data []byte) ([]byte, error) { 20 | hasher := Hasher.New() 21 | hasher.Write(data) 22 | hash := hasher.Sum(nil) 23 | return pair.Sign(Hasher, hash) 24 | } 25 | 26 | func (pair *KeyPair) Verify(signature []byte, hasher crypto.Hash, hash []byte) error { 27 | return rsa.VerifyPSS( 28 | pair.Public, 29 | hasher, 30 | hash, 31 | signature, 32 | &pssOpts) 33 | } 34 | 35 | func (pair *KeyPair) VerifyMessage(data []byte, signature []byte) error { 36 | hasher := Hasher.New() 37 | hasher.Write(data) 38 | hash := hasher.Sum(nil) 39 | // log.Info("hash(data) = %x", hash) 40 | // log.Info("signature = %x", signature) 41 | return pair.Verify(signature, Hasher, hash) 42 | } 43 | -------------------------------------------------------------------------------- /models/unit_enroll.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "github.com/evilsocket/islazy/log" 6 | ) 7 | 8 | func EnrollUnit(enroll EnrollmentRequest) (err error, unit *Unit) { 9 | if unit = FindUnitByFingerprint(enroll.Fingerprint); unit == nil { 10 | log.Info("enrolling new unit for %s (%s): %s", enroll.Address, enroll.Country, enroll.Identity) 11 | 12 | unit = &Unit{ 13 | Address: enroll.Address, 14 | Country: enroll.Country, 15 | Name: enroll.Name, 16 | Fingerprint: enroll.Fingerprint, 17 | PublicKey: string(enroll.KeyPair.PublicPEM), 18 | } 19 | 20 | if err := db.Create(unit).Error; err != nil { 21 | return fmt.Errorf("error enrolling %s: %v", unit.Identity(), err), nil 22 | } 23 | } 24 | 25 | if err := unit.updateToken(); err != nil { 26 | return fmt.Errorf("error creating token for %s: %v", unit.Identity(), err), nil 27 | } 28 | 29 | if err = unit.UpdateWith(enroll); err != nil { 30 | log.Debug("%+v", enroll) 31 | return fmt.Errorf("error setting token for %s: %v", unit.Identity(), err), nil 32 | } 33 | return nil, unit 34 | } 35 | -------------------------------------------------------------------------------- /models/message.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | ) 7 | 8 | const ( 9 | MessageDataMaxSize = 512000 10 | MessageSignatureMaxSize = 10000 11 | ) 12 | 13 | type Message struct { 14 | ID uint `gorm:"primary_key" json:"id"` 15 | CreatedAt time.Time `json:"created_at"` 16 | UpdatedAt time.Time `json:"updated_at"` 17 | DeletedAt *time.Time `sql:"index" json:"deleted_at"` 18 | SeenAt *time.Time `json:"seen_at" sql:"index"` 19 | SenderID uint `json:"-"` 20 | ReceiverID uint `json:"-"` 21 | SenderName string `gorm:"size:255" json:"sender_name"` 22 | Sender string `gorm:"size:255;not null" json:"sender"` 23 | Data string `gorm:"size:512000;not null" json:"-"` 24 | Signature string `gorm:"size:10000;not null" json:"-"` 25 | } 26 | 27 | func ValidateMessage(data, signature string) error { 28 | // validate max sizes 29 | if dataSize := len(data); dataSize > MessageDataMaxSize { 30 | return fmt.Errorf("max message data size is %d", MessageDataMaxSize) 31 | } else if sigSize := len(signature); sigSize > MessageSignatureMaxSize { 32 | return fmt.Errorf("max message signature size is %d", MessageSignatureMaxSize) 33 | } 34 | return nil 35 | } 36 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3' 2 | services: 3 | app: 4 | container_name: pwngrid_api 5 | build: . 6 | ports: 7 | - 8666:8666 8 | restart: on-failure 9 | volumes: 10 | - api:/usr/src/app/ 11 | depends_on: 12 | - pwngrid-mysql 13 | networks: 14 | - pwngrid 15 | 16 | pwngrid-mysql: 17 | image: mysql:5.7 18 | container_name: pwngrid_mysql 19 | ports: 20 | - 3306:3306 21 | environment: 22 | - MYSQL_ROOT_HOST=${DB_HOST} 23 | - MYSQL_USER=${DB_USER} 24 | - MYSQL_PASSWORD=${DB_PASSWORD} 25 | - MYSQL_DATABASE=${DB_NAME} 26 | - MYSQL_ROOT_PASSWORD=${DB_PASSWORD} 27 | volumes: 28 | - database_mysql:/var/lib/mysql 29 | networks: 30 | - pwngrid 31 | 32 | phpmyadmin: 33 | image: phpmyadmin/phpmyadmin 34 | container_name: pwngrid_phpmyadmin 35 | depends_on: 36 | - pwngrid-mysql 37 | environment: 38 | - PMA_HOST=pwngrid-mysql 39 | - PMA_USER=${DB_USER} 40 | - PMA_PORT=${DB_PORT} 41 | - PMA_PASSWORD=${DB_PASSWORD} 42 | ports: 43 | - 9090:80 44 | restart: always 45 | networks: 46 | - pwngrid 47 | 48 | volumes: 49 | api: 50 | database_mysql: 51 | 52 | networks: 53 | pwngrid: 54 | driver: bridge 55 | -------------------------------------------------------------------------------- /wifi/compression.go: -------------------------------------------------------------------------------- 1 | package wifi 2 | 3 | import ( 4 | "bytes" 5 | "compress/gzip" 6 | "fmt" 7 | "io" 8 | ) 9 | 10 | func Compress(data []byte) (bool, []byte, error) { 11 | oldSize := len(data) 12 | buf := bytes.Buffer{} 13 | if zw, err := gzip.NewWriterLevel(&buf, gzip.BestCompression); err != nil { 14 | return false, nil, fmt.Errorf("error initializing payload compression: %v", err) 15 | } else if _, err := zw.Write(data); err != nil { 16 | return false, nil, fmt.Errorf("error during payload compression: %v", err) 17 | } else if err = zw.Close(); err != nil { 18 | return false, nil, fmt.Errorf("error while finalizing payload compression: %v", err) 19 | } 20 | 21 | compressed := buf.Bytes() 22 | newSize := len(compressed) 23 | 24 | // log.Debug("gzip: %d > %d", oldSize, newSize) 25 | 26 | if newSize < oldSize { 27 | return true, compressed, nil 28 | } 29 | return false, data, nil 30 | } 31 | 32 | func Decompress(data []byte) ([]byte, error) { 33 | if zr, err := gzip.NewReader(bytes.NewBuffer(data)); err != nil { 34 | return nil, fmt.Errorf("error initializing payload decompression: %v", err) 35 | } else { 36 | defer func(zr *gzip.Reader) { 37 | err := zr.Close() 38 | if err != nil { 39 | 40 | } 41 | }(zr) 42 | return io.ReadAll(zr) 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /changelog.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | NEW=() 4 | FIXES=() 5 | MISC=() 6 | 7 | echo "@ Fetching remote tags ..." 8 | git fetch --tags >/dev/null 9 | printf "\n\n" 10 | 11 | CURTAG=$(git describe --tags --abbrev=0) 12 | OUTPUT=$(git log $CURTAG..HEAD --oneline) 13 | IFS=$'\n' LINES=($OUTPUT) 14 | 15 | for LINE in "${LINES[@]}"; do 16 | LINE=$(echo "$LINE" | sed -E "s/^[[:xdigit:]]+\s+//") 17 | if [[ $LINE == *"new:"* ]]; then 18 | LINE=$(echo "$LINE" | sed -E "s/^new: //") 19 | NEW+=("$LINE") 20 | elif [[ $LINE == *"fix:"* ]]; then 21 | LINE=$(echo "$LINE" | sed -E "s/^fix: //") 22 | FIXES+=("$LINE") 23 | elif [[ $LINE != *"i did not bother commenting"* ]] && [[ $LINE != *"Merge "* ]]; then 24 | echo " MISC LINE =$LINE" 25 | LINE=$(echo "$LINE" | sed -E "s/^[a-z]+: //") 26 | MISC+=("$LINE") 27 | fi 28 | done 29 | 30 | if [ -n "$NEW" ]; then 31 | echo 32 | echo "**New Features**" 33 | echo 34 | for l in "${NEW[@]}"; do 35 | echo "* $l" 36 | done 37 | fi 38 | 39 | if [ -n "$FIXES" ]; then 40 | echo 41 | echo "**Fixes**" 42 | echo 43 | for l in "${FIXES[@]}"; do 44 | echo "* $l" 45 | done 46 | fi 47 | 48 | if [ -n "$MISC" ]; then 49 | echo 50 | echo "**Misc**" 51 | echo 52 | for l in "${MISC[@]}"; do 53 | echo "* $l" 54 | done 55 | fi 56 | 57 | echo 58 | -------------------------------------------------------------------------------- /mesh/interface.go: -------------------------------------------------------------------------------- 1 | package mesh 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "github.com/jayofelony/pwngrid/utils" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | var chanParser = regexp.MustCompile(`^\s+Channel.([0-9]+)\s+:\s+([0-9.]+)\s+GHz.*$`) 13 | 14 | func ActivateInterface(name string) error { 15 | if out, err := utils.Exec("ifconfig", []string{name, "up"}); err != nil { 16 | return err 17 | } else if out != "" { 18 | return fmt.Errorf("unexpected output while activating interface %s: %s", name, out) 19 | } 20 | return nil 21 | } 22 | 23 | func SetChannel(iface string, channel int) (error, string) { 24 | if out, err := utils.Exec("iwconfig", []string{iface, "channel", fmt.Sprintf("%d", channel)}); err != nil { 25 | return err, out 26 | } else if out != "" { 27 | return fmt.Errorf("unexpected output while setting interface %s to channel %d: %s", iface, channel, out), out 28 | } else { 29 | return nil, out 30 | } 31 | } 32 | 33 | func SupportedChannels(iface string) ([]int, error) { 34 | out, err := utils.Exec("iwlist", []string{iface, "freq"}) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | var channels []int 40 | scanner := bufio.NewScanner(strings.NewReader(out)) 41 | for scanner.Scan() { 42 | line := scanner.Text() 43 | if matches := chanParser.FindStringSubmatch(line); len(matches) == 3 { 44 | if channel, err := strconv.ParseInt(matches[1], 10, 32); err == nil { 45 | channels = append(channels, int(channel)) 46 | } 47 | } 48 | } 49 | 50 | return channels, nil 51 | } 52 | -------------------------------------------------------------------------------- /models/unit_find.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | _ "github.com/jinzhu/gorm" 5 | ) 6 | 7 | type UnitsByCountry struct { 8 | Country string `json:"country"` 9 | Count int `json:"units"` 10 | } 11 | 12 | func GetUnitsByCountry() ([]UnitsByCountry, error) { 13 | results := make([]UnitsByCountry, 0) 14 | if err := db.Raw("SELECT country,COUNT(id) AS count FROM units GROUP BY country ORDER BY count DESC").Scan(&results).Error; err != nil { 15 | return nil, err 16 | } 17 | return results, nil 18 | } 19 | 20 | // GetPagedUnits returns a page of units, total number of units and total pages. 21 | // Uses a fixed limit of 25 per page to preserve previous behavior. 22 | func GetPagedUnits(page int) (units []Unit, total int, pages int) { 23 | const limit = 25 24 | if page < 1 { 25 | page = 1 26 | } 27 | 28 | var total64 int64 29 | if err := db.Model(&Unit{}).Count(&total64).Error; err != nil { 30 | return nil, 0, 0 31 | } 32 | total = int(total64) 33 | if total == 0 { 34 | return []Unit{}, 0, 0 35 | } 36 | 37 | offset := (page - 1) * limit 38 | if err := db.Order("id desc").Limit(limit).Offset(offset).Find(&units).Error; err != nil { 39 | return nil, 0, 0 40 | } 41 | 42 | pages = (total + limit - 1) / limit 43 | return units, total, pages 44 | } 45 | 46 | func FindUnit(id uint) *Unit { 47 | var unit Unit 48 | if err := db.Find(&unit, id).Error; err != nil { 49 | return nil 50 | } 51 | return &unit 52 | } 53 | 54 | func FindUnitByFingerprint(fingerprint string) *Unit { 55 | var unit Unit 56 | if fingerprint == "" { 57 | return nil 58 | } else if err := db.Where("fingerprint = ?", fingerprint).Take(&unit).Error; err != nil { 59 | return nil 60 | } 61 | return &unit 62 | } 63 | -------------------------------------------------------------------------------- /api/unit_enroll.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/evilsocket/islazy/log" 6 | "github.com/jayofelony/pwngrid/models" 7 | "io" 8 | "net/http" 9 | ) 10 | 11 | func (api *API) readEnrollment(w http.ResponseWriter, r *http.Request) (error, models.EnrollmentRequest) { 12 | var enroll models.EnrollmentRequest 13 | 14 | body, err := io.ReadAll(r.Body) 15 | if err != nil { 16 | ERROR(w, http.StatusUnprocessableEntity, err) 17 | return err, enroll 18 | } 19 | 20 | log.Debug("%s", body) 21 | 22 | enroll.Address = clientIP(r) 23 | enroll.Country = r.Header.Get("CF-IPCountry") 24 | 25 | if err = json.Unmarshal(body, &enroll); err != nil { 26 | log.Warning("error while reading enrollment request from %s: %v", enroll.Address, err) 27 | log.Debug("%s", body) 28 | ERROR(w, http.StatusUnprocessableEntity, err) 29 | return err, enroll 30 | } 31 | 32 | if err = enroll.Validate(); err != nil { 33 | log.Warning("error while validating enrollment request from %s: %v", enroll.Address, err) 34 | log.Debug("%s", body) 35 | ERROR(w, http.StatusUnprocessableEntity, ErrEmpty) 36 | return err, enroll 37 | } 38 | 39 | return nil, enroll 40 | } 41 | 42 | func (api *API) UnitEnroll(w http.ResponseWriter, r *http.Request) { 43 | err, enroll := api.readEnrollment(w, r) 44 | if err != nil { 45 | return 46 | } 47 | 48 | err, unit := models.EnrollUnit(enroll) 49 | if err != nil { 50 | log.Error("%v", err) 51 | ERROR(w, http.StatusInternalServerError, ErrEmpty) 52 | return 53 | } 54 | 55 | log.Debug("unit %s enrolled: id:%d address:%s", unit.Identity(), unit.ID, unit.Address) 56 | 57 | JSON(w, http.StatusOK, map[string]string{ 58 | "token": unit.Token, 59 | }) 60 | } 61 | -------------------------------------------------------------------------------- /api/setup_server.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "fmt" 5 | "github.com/evilsocket/islazy/log" 6 | "github.com/go-chi/chi/v5" 7 | "net/http" 8 | ) 9 | 10 | func cached(seconds int, next http.HandlerFunc) http.HandlerFunc { 11 | return func(w http.ResponseWriter, r *http.Request) { 12 | w.Header().Add("Cache-Control", fmt.Sprintf("public, max-age=%d", seconds)) 13 | w.Header().Add("Expires", fmt.Sprintf("%d", seconds)) 14 | next.ServeHTTP(w, r) 15 | } 16 | } 17 | 18 | func (api *API) setupServerRoutes() { 19 | log.Debug("registering server api ...") 20 | 21 | api.Router.Route("/api", func(r chi.Router) { 22 | r.Route("/v1", func(r chi.Router) { 23 | r.Route("/units", func(r chi.Router) { 24 | // GET /api/v1/units/ 25 | r.Get("/", cached(600, api.ListUnits)) 26 | // GET /api/v1/units/by_country 27 | r.Get("/by_country", cached(600, api.UnitsByCountry)) 28 | }) 29 | r.Route("/unit", func(r chi.Router) { 30 | // GET /api/v1/unit/ 31 | r.Get("/{fingerprint:[a-fA-F0-9]+}", cached(600, api.ShowUnit)) 32 | r.Route("/inbox", func(r chi.Router) { 33 | // GET /api/v1/unit/inbox/ 34 | r.Get("/", api.GetInbox) 35 | r.Route("/{msg_id:[0-9]+}", func(r chi.Router) { 36 | // GET /api/v1/unit/inbox/ 37 | r.Get("/", api.GetInboxMessage) 38 | // GET /api/v1/unit/inbox// 39 | r.Get("/{mark:[a-z]+}", api.MarkInboxMessage) 40 | }) 41 | }) 42 | // POST /api/v1/unit//inbox 43 | r.Post("/{fingerprint:[a-fA-F0-9]+}/inbox", api.SendMessageTo) 44 | // POST /api/v1/unit/enroll 45 | r.Post("/enroll", api.UnitEnroll) 46 | r.Route("/report", func(r chi.Router) { 47 | // POST /api/v1/unit/report/ap 48 | r.Post("/ap", api.UnitReportAP) 49 | // POST /api/v1/unit/report/aps 50 | r.Post("/aps", api.UnitReportMultipleAP) 51 | }) 52 | }) 53 | }) 54 | }) 55 | } 56 | -------------------------------------------------------------------------------- /api/setup_peer.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "github.com/evilsocket/islazy/log" 5 | "github.com/go-chi/chi/v5" 6 | ) 7 | 8 | func (api *API) setupPeerRoutes() { 9 | log.Debug("registering peer api ...") 10 | 11 | api.Router.Route("/api", func(r chi.Router) { 12 | r.Route("/v1", func(r chi.Router) { 13 | r.Route("/mesh", func(r chi.Router) { 14 | // GET /api/v1/mesh/peers 15 | r.Get("/peers", api.PeerGetPeers) 16 | 17 | r.Route("/memory", func(r chi.Router) { 18 | // GET /api/v1/mesh/memory 19 | r.Get("/", api.PeerGetMemory) 20 | // GET /api/v1/mesh/memory/ 21 | r.Get("/{fingerprint:[a-fA-F0-9]+}", api.PeerGetMemoryOf) 22 | }) 23 | 24 | // GET /api/v1/mesh/ 25 | r.Get("/{status:[a-z]+}", api.PeerSetSignaling) 26 | 27 | // GET /api/v1/mesh/data 28 | r.Get("/data", api.PeerGetMeshData) 29 | // POST /api/v1/mesh/data 30 | r.Post("/data", api.PeerSetMeshData) 31 | }) 32 | 33 | // GET /api/v1/data 34 | r.Post("/data", api.PeerGetData) 35 | // POST /api/v1/data 36 | r.Post("/data", api.PeerSetData) 37 | 38 | r.Route("/report", func(r chi.Router) { 39 | // POST /api/v1/report/ap 40 | r.Post("/ap", api.PeerReportAP) 41 | }) 42 | r.Route("/inbox", func(r chi.Router) { 43 | // GET /api/v1/inbox/ 44 | r.Get("/", api.PeerGetInbox) 45 | r.Route("/{msg_id:[0-9]+}", func(r chi.Router) { 46 | // GET /api/v1/inbox/ 47 | r.Get("/", api.PeerGetInboxMessage) 48 | // GET /api/v1/inbox// 49 | r.Get("/{mark:[a-z]+}", api.PeerMarkInboxMessage) 50 | }) 51 | }) 52 | r.Route("/unit", func(r chi.Router) { 53 | // POST /api/v1/unit//inbox 54 | r.Post("/{fingerprint:[a-fA-F0-9]+}/inbox", api.PeerSendMessageTo) 55 | }) 56 | r.Route("/units", func(r chi.Router) { 57 | // GET /api/v1/units/ 58 | r.Get("/", api.PeerListUnits) 59 | }) 60 | }) 61 | }) 62 | } 63 | -------------------------------------------------------------------------------- /api/utils.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "github.com/evilsocket/islazy/log" 7 | "net/http" 8 | "strconv" 9 | "strings" 10 | ) 11 | 12 | var ( 13 | ErrEmpty = errors.New("") 14 | ErrUnauthorized = errors.New("unauthorized") 15 | ) 16 | 17 | func clientIP(r *http.Request) string { 18 | address := strings.Split(r.RemoteAddr, ":")[0] 19 | if forwardedFor := r.Header.Get("X-Forwarded-For"); forwardedFor != "" { 20 | address = forwardedFor 21 | } 22 | // https://support.cloudflare.com/hc/en-us/articles/206776727-What-is-True-Client-IP- 23 | if trueClient := r.Header.Get("True-Client-IP"); trueClient != "" { 24 | address = trueClient 25 | } 26 | // handle multiple IPs case 27 | return strings.Trim(strings.Split(address, ",")[0], " ") 28 | } 29 | 30 | func reqToken(r *http.Request) string { 31 | keys := r.URL.Query() 32 | token := keys.Get("token") 33 | if token != "" { 34 | return token 35 | } 36 | bearerToken := r.Header.Get("Authorization") 37 | if parts := strings.Split(bearerToken, " "); len(parts) == 2 { 38 | return parts[1] 39 | } 40 | return "" 41 | } 42 | 43 | func pageNum(r *http.Request) (int, error) { 44 | pageParam := r.URL.Query().Get("p") 45 | if pageParam == "" { 46 | pageParam = "1" 47 | } 48 | return strconv.Atoi(pageParam) 49 | } 50 | 51 | func JSON(w http.ResponseWriter, statusCode int, data interface{}) { 52 | js, err := json.Marshal(data) 53 | if err != nil { 54 | http.Error(w, err.Error(), http.StatusInternalServerError) 55 | return 56 | } 57 | 58 | w.Header().Set("Content-Type", "application/json") 59 | w.WriteHeader(statusCode) 60 | 61 | if sent, err := w.Write(js); err != nil { 62 | log.Error("error sending response: %v", err) 63 | } else { 64 | log.Debug("sent %d bytes of json response", sent) 65 | } 66 | } 67 | 68 | func ERROR(w http.ResponseWriter, statusCode int, err error) { 69 | if err != nil { 70 | JSON(w, statusCode, struct { 71 | Error string `json:"error"` 72 | }{ 73 | Error: err.Error(), 74 | }) 75 | return 76 | } 77 | JSON(w, http.StatusBadRequest, nil) 78 | } 79 | -------------------------------------------------------------------------------- /mesh/peer_json.go: -------------------------------------------------------------------------------- 1 | package mesh 2 | 3 | import ( 4 | "encoding/json" 5 | "github.com/evilsocket/islazy/log" 6 | "net" 7 | "sync" 8 | "time" 9 | ) 10 | 11 | type jsonPeer struct { 12 | Fingerprint string `json:"fingerprint"` 13 | MetAt time.Time `json:"met_at"` 14 | DetectedAt time.Time `json:"detected_at"` 15 | SeenAt time.Time `json:"seen_at"` 16 | PrevSeenAt time.Time `json:"prev_seen_at"` 17 | Encounters uint64 `json:"encounters"` 18 | Channel int `json:"channel"` 19 | RSSI int `json:"rssi"` 20 | SessionID string `json:"session_id"` 21 | Advertisement map[string]interface{} `json:"advertisement"` 22 | } 23 | 24 | // creates a Peer object filled with the fields of the JSON representation 25 | func peerFromJSON(j jsonPeer) *Peer { 26 | peer := &Peer{ 27 | DetectedAt: j.DetectedAt, 28 | SeenAt: j.SeenAt, 29 | PrevSeenAt: j.PrevSeenAt, 30 | SessionIDStr: j.SessionID, 31 | Encounters: j.Encounters, 32 | Channel: j.Channel, 33 | RSSI: j.RSSI, 34 | AdvData: sync.Map{}, 35 | } 36 | 37 | if hw, err := net.ParseMAC(j.SessionID); err == nil { 38 | copy(peer.SessionID, hw) 39 | } else { 40 | log.Warning("error parsing peer session id %s: %v", j.SessionID, err) 41 | } 42 | 43 | for key, val := range j.Advertisement { 44 | peer.AdvData.Store(key, val) 45 | } 46 | 47 | return peer 48 | } 49 | 50 | // converts a peer into a JSON friendly representation 51 | func (peer *Peer) json() *jsonPeer { 52 | fingerprint := "" 53 | if v, found := peer.AdvData.Load("identity"); found { 54 | fingerprint = v.(string) 55 | } 56 | 57 | doc := jsonPeer{ 58 | Fingerprint: fingerprint, 59 | MetAt: peer.MetAt, 60 | Encounters: peer.Encounters, 61 | PrevSeenAt: peer.PrevSeenAt, 62 | DetectedAt: peer.DetectedAt, 63 | SeenAt: peer.SeenAt, 64 | Channel: peer.Channel, 65 | RSSI: peer.RSSI, 66 | SessionID: peer.SessionIDStr, 67 | Advertisement: make(map[string]interface{}), 68 | } 69 | peer.AdvData.Range(func(key, value interface{}) bool { 70 | doc.Advertisement[key.(string)] = value 71 | return true 72 | }) 73 | 74 | return &doc 75 | } 76 | 77 | func (peer *Peer) MarshalJSON() ([]byte, error) { 78 | peer.Lock() 79 | defer peer.Unlock() 80 | return json.Marshal(peer.json()) 81 | } 82 | -------------------------------------------------------------------------------- /wifi/pack.go: -------------------------------------------------------------------------------- 1 | package wifi 2 | 3 | import ( 4 | "bytes" 5 | "encoding/binary" 6 | "github.com/gopacket/gopacket" 7 | "github.com/gopacket/gopacket/layers" 8 | "net" 9 | ) 10 | 11 | func Info(id layers.Dot11InformationElementID, info []byte) *layers.Dot11InformationElement { 12 | return &layers.Dot11InformationElement{ 13 | ID: id, 14 | Length: uint8(len(info) & 0xff), 15 | Info: info, 16 | } 17 | } 18 | 19 | func PackOneOf(from, to net.HardwareAddr, peerID []byte, signature []byte, streamID uint64, seqNum uint64, seqTot uint64, payload []byte, compress bool) (error, []byte) { 20 | stack := []gopacket.SerializableLayer{ 21 | &layers.RadioTap{}, 22 | &layers.Dot11{ 23 | Address1: to, 24 | Address2: SignatureAddr, 25 | Address3: from, 26 | Type: layers.Dot11TypeMgmtBeacon, 27 | }, 28 | &layers.Dot11MgmtBeacon{ 29 | Flags: uint16(wpaFlags), 30 | Interval: 100, 31 | }, 32 | } 33 | 34 | if peerID != nil { 35 | stack = append(stack, Info(IDWhisperIdentity, peerID)) 36 | } 37 | 38 | if signature != nil { 39 | stack = append(stack, Info(IDWhisperSignature, signature)) 40 | } 41 | 42 | if streamID > 0 { 43 | streamBuf := new(bytes.Buffer) 44 | if err := binary.Write(streamBuf, binary.LittleEndian, streamID); err != nil { 45 | return err, nil 46 | } else if err = binary.Write(streamBuf, binary.LittleEndian, seqNum); err != nil { 47 | return err, nil 48 | } else if err = binary.Write(streamBuf, binary.LittleEndian, seqTot); err != nil { 49 | return err, nil 50 | } 51 | stack = append(stack, Info(IDWhisperStreamHeader, streamBuf.Bytes())) 52 | } 53 | 54 | if compress { 55 | if didCompress, compressed, err := Compress(payload); err != nil { 56 | return err, nil 57 | } else if didCompress { 58 | stack = append(stack, Info(IDWhisperCompression, []byte{1})) 59 | payload = compressed 60 | } 61 | } 62 | 63 | dataSize := len(payload) 64 | dataLeft := dataSize 65 | dataOff := 0 66 | chunkSize := 0xff 67 | 68 | for dataLeft > 0 { 69 | sz := chunkSize 70 | if dataLeft < chunkSize { 71 | sz = dataLeft 72 | } 73 | 74 | chunk := payload[dataOff : dataOff+sz] 75 | stack = append(stack, Info(IDWhisperPayload, chunk)) 76 | 77 | dataOff += sz 78 | dataLeft -= sz 79 | } 80 | 81 | return Serialize(stack...) 82 | } 83 | 84 | func Pack(from, to net.HardwareAddr, payload []byte, compress bool) (error, []byte) { 85 | return PackOneOf(from, to, nil, nil, 0, 0, 0, payload, compress) 86 | } 87 | -------------------------------------------------------------------------------- /api/auth.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "github.com/evilsocket/islazy/log" 7 | "github.com/golang-jwt/jwt/v5" 8 | "github.com/jayofelony/pwngrid/models" 9 | "net/http" 10 | "os" 11 | "time" 12 | ) 13 | 14 | var ( 15 | ErrTokenClaims = errors.New("can't extract claims from jwt token") 16 | ErrTokenInvalid = errors.New("jwt token not valid") 17 | ErrTokenExpired = errors.New("jwt token expired") 18 | ErrTokenIncomplete = errors.New("jwt token is missing required fields") 19 | ErrTokenUnauthorized = errors.New("jwt token authorized field is false (?!)") 20 | ) 21 | 22 | func validateToken(header string) (jwt.MapClaims, error) { 23 | token, err := jwt.Parse(header, func(token *jwt.Token) (interface{}, error) { 24 | if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok { 25 | return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"]) 26 | } 27 | return []byte(os.Getenv("API_SECRET")), nil 28 | }) 29 | if err != nil { 30 | return nil, err 31 | } 32 | 33 | claims, ok := token.Claims.(jwt.MapClaims) 34 | if !ok { 35 | return nil, ErrTokenClaims 36 | } else if !token.Valid { 37 | return nil, ErrTokenInvalid 38 | } 39 | 40 | required := []string{ 41 | "expires_at", 42 | "authorized", 43 | "unit_id", 44 | "unit_ident", 45 | } 46 | for _, req := range required { 47 | if _, found := claims[req]; !found { 48 | return nil, ErrTokenIncomplete 49 | } 50 | } 51 | 52 | log.Debug("%+v", claims) 53 | 54 | if expiresAt, err := time.Parse(time.RFC3339, claims["expires_at"].(string)); err != nil { 55 | return nil, ErrTokenExpired 56 | } else if expiresAt.Before(time.Now()) { 57 | return nil, ErrTokenExpired 58 | } else if claims["authorized"].(bool) != true { 59 | return nil, ErrTokenUnauthorized 60 | } 61 | return claims, err 62 | } 63 | 64 | func Authenticate(w http.ResponseWriter, r *http.Request) *models.Unit { 65 | client := clientIP(r) 66 | tokenHeader := reqToken(r) 67 | if tokenHeader == "" { 68 | log.Debug("unauthenticated request from %s", client) 69 | ERROR(w, http.StatusUnauthorized, ErrUnauthorized) 70 | return nil 71 | } 72 | 73 | claims, err := validateToken(tokenHeader) 74 | if err != nil { 75 | log.Debug("token error for %s: %v", client, err) 76 | ERROR(w, http.StatusUnauthorized, ErrUnauthorized) 77 | return nil 78 | } 79 | 80 | log.Debug("claims[unit_id] = %+v", claims["unit_id"]) 81 | unit := models.FindUnit(uint(claims["unit_id"].(float64))) 82 | if unit == nil { 83 | log.Warning("client %s authenticated with unknown claims '%v'", client, claims) 84 | ERROR(w, http.StatusUnauthorized, ErrUnauthorized) 85 | return nil 86 | } 87 | 88 | return unit 89 | } 90 | -------------------------------------------------------------------------------- /models/enrollment.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/base64" 5 | "fmt" 6 | "github.com/evilsocket/islazy/str" 7 | "github.com/jayofelony/pwngrid/crypto" 8 | "regexp" 9 | "strings" 10 | ) 11 | 12 | type EnrollmentRequest struct { 13 | Identity string `json:"identity"` // name@SHA256(public_key) 14 | PublicKey string `json:"public_key"` // BASE64(public_key.pem) 15 | Signature string `json:"signature"` // BASE64(SIGN(identity, private_key)) 16 | Data map[string]interface{} `json:"data"` // misc data for the unit 17 | KeyPair *crypto.KeyPair `json:"-"` // parsed from public_key 18 | Name string `json:"-"` 19 | Fingerprint string `json:"-"` // SHA256(public_key) 20 | Address string `json:"-"` 21 | Country string `json:"-"` 22 | } 23 | 24 | var ansi = regexp.MustCompile("\033\\[(?:[0-9]{1,3}(?:;[0-9]{1,3})*)?[m|K]") 25 | 26 | func clean(s string) string { 27 | for _, m := range ansi.FindAllString(s, -1) { 28 | s = strings.Replace(s, m, "", -1) 29 | } 30 | return str.Trim(s) 31 | } 32 | 33 | func (enroll *EnrollmentRequest) Validate() error { 34 | // split the identity into name and fingerprint 35 | parts := strings.Split(enroll.Identity, "@") 36 | if len(parts) != 2 { 37 | return fmt.Errorf("error parsing the identity string: got %d parts", len(parts)) 38 | } 39 | 40 | enroll.Name = clean(parts[0]) 41 | enroll.Fingerprint = clean(parts[1]) 42 | if len(enroll.Fingerprint) != crypto.Hasher.Size()*2 { 43 | return fmt.Errorf("unexpected fingerprint length for %s", enroll.Fingerprint) 44 | } 45 | 46 | // parse the public key as b64 pem 47 | pubKeyPEM, err := base64.StdEncoding.DecodeString(enroll.PublicKey) 48 | if err != nil { 49 | return fmt.Errorf("error decoding the public key: %v", err) 50 | } 51 | 52 | enroll.KeyPair, err = crypto.FromPublicPEM(string(pubKeyPEM)) 53 | if err != nil { 54 | return fmt.Errorf("error parsing the public key: %v", err) 55 | } 56 | 57 | enroll.PublicKey = string(enroll.KeyPair.PublicPEM) 58 | 59 | if enroll.KeyPair.FingerprintHex != enroll.Fingerprint { 60 | return fmt.Errorf("fingerprint mismatch: expected:%s got:%s", enroll.KeyPair.FingerprintHex, enroll.Fingerprint) 61 | } 62 | 63 | data := []byte(enroll.Identity) 64 | signature, err := base64.StdEncoding.DecodeString(enroll.Signature) 65 | if err != nil { 66 | return fmt.Errorf("error decoding the signature: %v", err) 67 | } 68 | 69 | if err := enroll.KeyPair.VerifyMessage(data, signature); err != nil { 70 | return fmt.Errorf("signature verification failed: %s", err) 71 | } 72 | 73 | return nil 74 | } 75 | -------------------------------------------------------------------------------- /mesh/memory.go: -------------------------------------------------------------------------------- 1 | package mesh 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/evilsocket/islazy/fs" 7 | "github.com/evilsocket/islazy/log" 8 | "math" 9 | "os" 10 | "path" 11 | "sync" 12 | "time" 13 | ) 14 | 15 | type Memory struct { 16 | sync.Mutex 17 | path string 18 | peers map[string]*Peer 19 | } 20 | 21 | func MemoryFromPath(path string) (err error, mem *Memory) { 22 | if path, err = fs.Expand(path); err != nil { 23 | return err, nil 24 | } 25 | 26 | mem = &Memory{ 27 | path: path, 28 | peers: make(map[string]*Peer), 29 | } 30 | 31 | if !fs.Exists(path) { 32 | log.Debug("creating %s ...", path) 33 | if err = os.MkdirAll(path, os.ModePerm); err != nil { 34 | return 35 | } 36 | } 37 | 38 | err = fs.Glob(path, "*.json", func(fileName string) error { 39 | log.Debug("loading %s ...", fileName) 40 | data, err := os.ReadFile(fileName) 41 | if err != nil { 42 | log.Error("error loading %s: %v", fileName, err) 43 | return nil 44 | } 45 | 46 | var peer jsonPeer 47 | if err = json.Unmarshal(data, &peer); err != nil { 48 | log.Error("error loading %s: %v", fileName, err) 49 | return nil 50 | } 51 | 52 | mem.peers[peer.Fingerprint] = peerFromJSON(peer) 53 | return nil 54 | }) 55 | 56 | log.Debug("loaded %d known peers", len(mem.peers)) 57 | 58 | return 59 | } 60 | 61 | func (mem *Memory) Size() int { 62 | mem.Lock() 63 | defer mem.Unlock() 64 | return len(mem.peers) 65 | } 66 | 67 | func (mem *Memory) Of(fingerprint string) *Peer { 68 | mem.Lock() 69 | defer mem.Unlock() 70 | 71 | if peer, found := mem.peers[fingerprint]; found { 72 | return peer 73 | } 74 | 75 | return nil 76 | } 77 | 78 | func (mem *Memory) List() []*Peer { 79 | mem.Lock() 80 | defer mem.Unlock() 81 | 82 | list := make([]*Peer, 0) 83 | for _, peer := range mem.peers { 84 | list = append(list, peer) 85 | } 86 | 87 | return list 88 | } 89 | 90 | func (mem *Memory) Track(fingerprint string, peer *Peer) error { 91 | mem.Lock() 92 | defer mem.Unlock() 93 | 94 | if encounter, found := mem.peers[fingerprint]; !found { 95 | // peer first encounter 96 | peer.Encounters = 1 97 | peer.MetAt = time.Now() 98 | peer.PrevSeenAt = peer.SeenAt 99 | } else { 100 | // we met this peer before 101 | if encounter.Encounters < math.MaxUint64 { 102 | encounter.Encounters++ 103 | } 104 | peer.PrevSeenAt = encounter.SeenAt 105 | peer.MetAt = encounter.MetAt 106 | peer.Encounters = encounter.Encounters 107 | } 108 | 109 | peer.SeenAt = time.Now() 110 | 111 | // save/update peer data in memory 112 | mem.peers[fingerprint] = peer 113 | // save/update peer data on disk 114 | fileName := path.Join(mem.path, fmt.Sprintf("%s.json", fingerprint)) 115 | if data, err := json.Marshal(peer); err != nil { 116 | return err 117 | } else if err := os.WriteFile(fileName, data, os.ModePerm); err != nil { 118 | return err 119 | } 120 | 121 | return nil 122 | } 123 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: false 2 | os: linux 3 | dist: bionic 4 | language: go 5 | go: 6 | - 1.13.x 7 | 8 | env: 9 | global: 10 | - LANG=C 11 | - LC_ALL=C 12 | - OUTPUT="pwngrid" 13 | - VERSION=$(echo ${TRAVIS_BRANCH} | sed "s/\//_/g") 14 | 15 | cache: 16 | apt: true 17 | addons: 18 | apt: 19 | packages: 20 | - wget 21 | - p7zip-full 22 | - libpcap-dev 23 | update: true 24 | 25 | cross: &cross 26 | before_install: 27 | - wget --show-progress -qcO "qemu.deb" "https://debian.grena.ge/debian/pool/main/q/qemu/qemu-user-static_4.1-1+b4_amd64.deb" 28 | - sudo dpkg -i "qemu.deb" 29 | install: 30 | - sudo builder/arm_builder.sh pwngrid make -e TARGET="${OUTPUT}" 31 | 32 | normal: &normal 33 | install: 34 | - make -e TARGET="${OUTPUT}" 35 | 36 | end: &end 37 | after_success: 38 | - sudo mv "build/${OUTPUT}" "${OUTPUT}" 39 | - file "${OUTPUT}" 40 | - openssl dgst -sha256 "${OUTPUT}" | tee "${OUTPUT}_${TARGET_OS}_${TARGET_ARCH}_${VERSION}.sha256" 41 | - 7z a "${OUTPUT}_${TARGET_OS}_${TARGET_ARCH}_${VERSION}.zip" "${OUTPUT}" "${OUTPUT}_${TARGET_OS}_${TARGET_ARCH}_${VERSION}.sha256" 42 | 43 | matrix: 44 | include: 45 | - name: Linux - amd64 46 | if: tag IS present 47 | arch: amd64 48 | env: 49 | - TARGET_OS=linux 50 | - TARGET_ARCH=amd64 51 | <<: *normal 52 | <<: *end 53 | - name: Linux - aarch64 54 | if: tag IS present 55 | arch: arm64 56 | env: 57 | - TARGET_OS=linux 58 | - TARGET_ARCH=aarch64 59 | <<: *normal 60 | <<: *end 61 | - name: Linux - armhf 62 | if: tag IS present 63 | arch: amd64 64 | language: minimal 65 | env: 66 | - TARGET_OS=linux 67 | - TARGET_ARCH=armhf 68 | <<: *cross 69 | <<: *end 70 | # Tests 71 | # - name: Linux - tests 72 | # if: tag IS blank 73 | # os: linux 74 | # arch: amd64 75 | # install: 76 | # - make deps 77 | # script: 78 | # - make test 79 | # after_success: 80 | # - bash <(curl -s https://codecov.io/bash) 81 | 82 | deploy: 83 | provider: releases 84 | api_key: 85 | secure: ljBVe/wVAtOPwCWJPlJ7D1hWGfm6GtHOLgq3wmP4jw/9a2RYV41xJ7g+4R1mm9R8waqtTm9QPDHIKFuN3N9cNs83ZY/fkSJ2WwU3IDV1ZvKPAuucrMSsyOGc08poXj6mmUDs/9LRb100qG81Y5dD+WB6Ep6vWOT7aOi9QNp/WWQ3IDYp5QJIocRHcJhGFH8JO1699mpdNgaukmPHIYK6uVu15TCkYOrvNTD0OTpthN6hIwCBwQ0agFNBbqmwyYsAdUZsjdU7QVOCnPUeXWqoZwq3klFKymsf8f4xra7ou5hsBkL+GFESiGGy0TdU7ZTZjPRKgkpIWtHOURq3WSVtYvCTnKI8h+HdBbKlQeO5g611gUw3CEU5HZxKlG18JTSD5TJNuEAFBVA7X385cVnWbgCLIwLiCDzjKPeVJvyDxyKC1CCtmfYZtanyn18qz/VRtMWrLFRcr5jNrQcloiuJbQzteoxtvbt5c0nM7b1b/AZ4zzGH75MLADxbHw2CThN4R+dxx3lqD0YM3fxbWiWCgZlbrc7GNRq1ilhX0YEDBOVfcdJxYARwrzovrO8bMFGerur4C7HzkpVgE6jfQiZJdXU/5javkLnww6xjDC/jfiMJ7i4OqZ2sgISSVL8Fq4LMqaAumdSHZK/GoJ97PTWUE9sBD7tIGSzHReA8DhpweFI= 86 | skip_cleanup: true 87 | file_glob: true 88 | file: 89 | - pwngrid_*.zip 90 | - pwngrid_*.sha256 91 | on: 92 | tags: true 93 | repo: evilsocket/pwngrid 94 | branches: 95 | only: 96 | - "/^v[0-9]+\\.[0-9]+\\.[0-9]+[A-Za-z0-9]+?$/" 97 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | VERSION := $(shell sed -n 's/Version\s*=\s*"\([0-9.]\+\)"/\1/p' version/ver.go | tr -d '\t') 2 | 3 | all: clean 4 | @mkdir build 5 | @go build -o build/pwngrid cmd/pwngrid/*.go 6 | @ls -la build/pwngrid 7 | 8 | install: 9 | @cp build/pwngrid /usr/local/bin/ 10 | @mkdir -p /etc/systemd/system/ 11 | @mkdir -p /etc/pwngrid/ 12 | @cp env.example /etc/pwngrid/pwngrid.conf 13 | @systemctl daemon-reload 14 | 15 | clean: 16 | @rm -rf build 17 | 18 | restart: 19 | @service pwngrid restart 20 | 21 | release_files: clean cross_compile_libpcap_arm64 # cross_compile_libpcap_arm 22 | @mkdir build 23 | @echo building for linux/amd64 ... 24 | @CGO_ENABLED=1 CC=x86_64-linux-gnu-gcc GOARCH=amd64 GOOS=linux go build -o build/pwngrid cmd/pwngrid/*.go 25 | @openssl dgst -sha256 "build/pwngrid" > "build/pwngrid-amd64.sha256" 26 | @zip -j "build/pwngrid-$(VERSION)-amd64.zip" build/pwngrid build/pwngrid-amd64.sha256 > /dev/null 27 | @rm -rf build/pwngrid build/pwngrid-amd64.sha256 28 | # @echo building for linux/armv6l ... 29 | # @CGO_ENABLED=1 CC=arm-linux-gnueabi-gcc GOARM=6 GOARCH=arm GOOS=linux go build -o build/pwngrid cmd/pwngrid/*.go 30 | # @openssl dgst -sha256 "build/pwngrid" > "build/pwngrid-armv6l.sha256" 31 | # @zip -j "build/pwngrid-$(VERSION)-armv6l.zip" build/pwngrid build/pwngrid-armv6l.sha256 > /dev/null 32 | # @rm -rf build/pwngrid build/pwngrid-armv6l.sha256 33 | @echo building for linux/aarch64 ... 34 | @CGO_ENABLED=1 CC=aarch64-linux-gnu-gcc GOARCH=arm64 GOOS=linux go build -o build/pwngrid cmd/pwngrid/*.go 35 | @openssl dgst -sha256 "build/pwngrid" > "build/pwngrid-aarch64.sha256" 36 | @zip -j "build/pwngrid-$(VERSION)-aarch64.zip" build/pwngrid build/pwngrid-aarch64.sha256 > /dev/null 37 | @rm -rf build/pwngrid build/pwngrid-aarch64.sha256 38 | @ls -la build 39 | 40 | # requires sudo apt-get install bison flex gcc-arm-linux-gnueabi libpcap0.8 libpcap-dev 41 | cross_compile_libpcap_arm: 42 | @echo "Cross-compiling libpcap for armv6l..." 43 | @wget https://www.tcpdump.org/release/libpcap-1.9.1.tar.gz 44 | @tar -zxvf libpcap-1.9.1.tar.gz 45 | @cd libpcap-1.9.1 && \ 46 | export CC=arm-linux-gnueabi-gcc && \ 47 | ./configure --host=arm-linux-gnueabi && \ 48 | make 49 | @echo "Copying cross-compiled libpcap to /usr/lib/arm-linux-gnueabi/" 50 | @sudo cp libpcap-1.9.1/libpcap.a /usr/lib/arm-linux-gnueabi/ 51 | @echo "Clean up..." 52 | @rm -rf libpcap-1.9.1 libpcap-1.9.1.tar.gz 53 | 54 | # requires sudo apt-get install bison flex gcc-aarch64-linux-gnu libpcap0.8 libpcap-dev 55 | cross_compile_libpcap_arm64: 56 | @echo "Cross-compiling libpcap for arm64..." 57 | @wget https://www.tcpdump.org/release/libpcap-1.9.1.tar.gz 58 | @tar -zxvf libpcap-1.9.1.tar.gz 59 | @cd libpcap-1.9.1 && \ 60 | export CC=aarch64-linux-gnu-gcc && \ 61 | ./configure --host=aarch64-linux-gnu && \ 62 | make 63 | @echo "Copying cross-compiled libpcap to /usr/lib/x86_64-linux-gnu/" 64 | @sudo cp libpcap-1.9.1/libpcap.a /usr/lib/aarch64-linux-gnu/ 65 | @echo "Clean up..." 66 | @rm -rf libpcap-1.9.1 libpcap-1.9.1.tar.gz 67 | 68 | .PHONY: cross_compile_libpcap_arm cross_compile_libpcap_arm64 -------------------------------------------------------------------------------- /api/unit_report.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/evilsocket/islazy/log" 7 | "github.com/jayofelony/pwngrid/models" 8 | "io" 9 | "net" 10 | "net/http" 11 | ) 12 | 13 | type apReport struct { 14 | ESSID string `json:"essid"` 15 | BSSID string `json:"bssid"` 16 | } 17 | 18 | func (api *API) unitReport(client string, unit *models.Unit, ap apReport) error { 19 | if parsed, err := net.ParseMAC(ap.BSSID); err != nil { 20 | return fmt.Errorf("error while parsing wifi ap bssid %s from %s: %v", ap.BSSID, client, err) 21 | } else { 22 | // normalize 23 | ap.BSSID = parsed.String() 24 | } 25 | 26 | if existing := unit.FindAccessPoint(ap.ESSID, ap.BSSID); existing == nil { 27 | log.Debug("unit %s (%s %s) reporting new wifi access point %v", unit.Identity(), unit.Address, 28 | unit.Country, ap) 29 | 30 | newAP := models.AccessPoint{ 31 | Name: ap.ESSID, 32 | Mac: ap.BSSID, 33 | UnitID: unit.ID, 34 | } 35 | 36 | if err := models.Create(&newAP).Error; err != nil { 37 | return fmt.Errorf("error creating ap %v: %v", newAP, err) 38 | } 39 | } else if err := models.Update(existing).Error; err != nil { 40 | return fmt.Errorf("error updating ap %v: %v", existing, err) 41 | } 42 | 43 | return nil 44 | } 45 | 46 | func (api *API) UnitReportAP(w http.ResponseWriter, r *http.Request) { 47 | unit := Authenticate(w, r) 48 | if unit == nil { 49 | return 50 | } 51 | 52 | client := clientIP(r) 53 | body, err := io.ReadAll(r.Body) 54 | if err != nil { 55 | ERROR(w, http.StatusUnprocessableEntity, ErrEmpty) 56 | return 57 | } 58 | 59 | var ap apReport 60 | if err = json.Unmarshal(body, &ap); err != nil { 61 | log.Warning("error while reading wifi ap from %s: %v", client, err) 62 | ERROR(w, http.StatusUnprocessableEntity, ErrEmpty) 63 | return 64 | } 65 | 66 | if err := api.unitReport(client, unit, ap); err != nil { 67 | log.Warning("%v", err) 68 | ERROR(w, http.StatusUnprocessableEntity, ErrEmpty) 69 | return 70 | } 71 | 72 | JSON(w, http.StatusOK, map[string]interface{}{ 73 | "success": true, 74 | }) 75 | } 76 | 77 | func (api *API) UnitReportMultipleAP(w http.ResponseWriter, r *http.Request) { 78 | unit := Authenticate(w, r) 79 | if unit == nil { 80 | return 81 | } 82 | 83 | client := clientIP(r) 84 | body, err := io.ReadAll(r.Body) 85 | if err != nil { 86 | ERROR(w, http.StatusUnprocessableEntity, ErrEmpty) 87 | return 88 | } 89 | 90 | var aps []apReport 91 | if err = json.Unmarshal(body, &aps); err != nil { 92 | log.Warning("error while reading wifi ap list from %s: %v", client, err) 93 | ERROR(w, http.StatusUnprocessableEntity, ErrEmpty) 94 | return 95 | } 96 | 97 | for _, ap := range aps { 98 | if err := api.unitReport(client, unit, ap); err != nil { 99 | log.Warning("%v", err) 100 | ERROR(w, http.StatusUnprocessableEntity, ErrEmpty) 101 | return 102 | } 103 | } 104 | 105 | JSON(w, http.StatusOK, map[string]interface{}{ 106 | "success": true, 107 | }) 108 | } 109 | -------------------------------------------------------------------------------- /crypto/encrypt.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto/aes" 5 | "crypto/cipher" 6 | "crypto/rand" 7 | "crypto/rsa" 8 | "encoding/binary" 9 | "fmt" 10 | "io" 11 | ) 12 | 13 | const ( 14 | AESKEyLength = 32 15 | NonceLength = 12 16 | ) 17 | 18 | func (pair *KeyPair) EncryptFor(cleartext []byte, pubKey *rsa.PublicKey) ([]byte, error) { 19 | // generate a random 32 bytes long key 20 | key := make([]byte, AESKEyLength) 21 | if _, err := io.ReadFull(rand.Reader, key); err != nil { 22 | return nil, err 23 | } 24 | 25 | // encrypt the key with RSA 26 | encKey, err := pair.EncryptBlockFor(key, pubKey) 27 | if err != nil { 28 | return nil, err 29 | } 30 | 31 | // use that key to encrypt the cleartext in AES-GCM 32 | block, err := aes.NewCipher(key) 33 | if err != nil { 34 | return nil, err 35 | } 36 | 37 | nonce := make([]byte, NonceLength) 38 | if _, err := io.ReadFull(rand.Reader, nonce); err != nil { 39 | return nil, err 40 | } 41 | 42 | gcm, err := cipher.NewGCM(block) 43 | if err != nil { 44 | return nil, err 45 | } 46 | 47 | encrypted := gcm.Seal(nil, nonce, cleartext, nil) 48 | 49 | keySizeBuf := make([]byte, 4) 50 | binary.LittleEndian.PutUint32(keySizeBuf, uint32(len(encKey))) 51 | 52 | // send all 53 | encrypted = append(encKey, encrypted...) // key enc 54 | encrypted = append(keySizeBuf, encrypted...) // ksz key enc 55 | encrypted = append(nonce, encrypted...) // nonce ksz key enc 56 | 57 | return encrypted, nil 58 | } 59 | 60 | func (pair *KeyPair) EncryptBlockFor(block []byte, pubKey *rsa.PublicKey) ([]byte, error) { 61 | return rsa.EncryptOAEP( 62 | Hasher.New(), 63 | rand.Reader, 64 | pubKey, 65 | block, 66 | []byte("")) 67 | } 68 | 69 | func (pair *KeyPair) DecryptBlock(block []byte) ([]byte, error) { 70 | return rsa.DecryptOAEP( 71 | Hasher.New(), 72 | rand.Reader, 73 | pair.Private, 74 | block, 75 | []byte("")) 76 | } 77 | 78 | func (pair *KeyPair) Decrypt(ciphertext []byte) ([]byte, error) { 79 | dataAvailable := len(ciphertext) 80 | if dataAvailable < NonceLength { 81 | return nil, fmt.Errorf("data buffer too short") 82 | } 83 | 84 | nonce := ciphertext[0:NonceLength] 85 | dataAvailable -= NonceLength 86 | 87 | if dataAvailable < 4 { 88 | return nil, fmt.Errorf("data buffer too short") 89 | } 90 | 91 | keySizeBuf := ciphertext[NonceLength : NonceLength+4] 92 | keySize := binary.LittleEndian.Uint32(keySizeBuf) 93 | dataAvailable -= 4 94 | 95 | if dataAvailable < int(keySize) { 96 | return nil, fmt.Errorf("data buffer too short") 97 | } 98 | 99 | encKey := ciphertext[NonceLength+4 : NonceLength+4+keySize] 100 | ciphertext = ciphertext[NonceLength+4+keySize:] 101 | 102 | // decrypt the key 103 | key, err := pair.DecryptBlock(encKey) 104 | if err != nil { 105 | return nil, err 106 | } 107 | 108 | // decrypt the payload 109 | block, err := aes.NewCipher(key) 110 | if err != nil { 111 | return nil, err 112 | } 113 | 114 | gcm, err := cipher.NewGCM(block) 115 | if err != nil { 116 | return nil, err 117 | } 118 | 119 | return gcm.Open(nil, nonce, ciphertext, nil) 120 | } 121 | -------------------------------------------------------------------------------- /api/peer_mesh.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | "net/http" 7 | "sort" 8 | 9 | "github.com/evilsocket/islazy/log" 10 | "github.com/go-chi/chi/v5" 11 | "github.com/jayofelony/pwngrid/mesh" 12 | ) 13 | 14 | // PeerGetPeers GET /api/v1/mesh/peers 15 | func (api *API) PeerGetPeers(w http.ResponseWriter, r *http.Request) { 16 | peers := make([]*mesh.Peer, 0) 17 | mesh.Peers.Range(func(key, value interface{}) bool { 18 | peers = append(peers, value.(*mesh.Peer)) 19 | return true 20 | }) 21 | 22 | // closer first 23 | sort.Slice(peers, func(i, j int) bool { 24 | return peers[i].RSSI > peers[j].RSSI 25 | }) 26 | 27 | JSON(w, http.StatusOK, peers) 28 | } 29 | 30 | // PeerGetMemory GET /api/v1/mesh/memory 31 | func (api *API) PeerGetMemory(w http.ResponseWriter, r *http.Request) { 32 | peers := api.Mesh.Memory() 33 | // higher number of encounters first 34 | sort.Slice(peers, func(i, j int) bool { 35 | return peers[i].Encounters > peers[j].Encounters 36 | }) 37 | JSON(w, http.StatusOK, peers) 38 | } 39 | 40 | // PeerGetMemoryOf GET /api/v1/mesh/memory/ 41 | func (api *API) PeerGetMemoryOf(w http.ResponseWriter, r *http.Request) { 42 | fingerprint := chi.URLParam(r, "fingerprint") 43 | peer := api.Mesh.MemoryOf(fingerprint) 44 | if peer == nil { 45 | ERROR(w, http.StatusNotFound, ErrEmpty) 46 | return 47 | } 48 | JSON(w, http.StatusOK, peer) 49 | } 50 | 51 | // PeerSetSignaling GET /api/v1/mesh/ 52 | func (api *API) PeerSetSignaling(w http.ResponseWriter, r *http.Request) { 53 | status := chi.URLParam(r, "status") 54 | 55 | if status == "enabled" || status == "true" { 56 | api.Peer.Advertise(true) 57 | } else if status == "disabled" || status == "false" { 58 | api.Peer.Advertise(false) 59 | } else { 60 | ERROR(w, http.StatusNotFound, ErrEmpty) 61 | return 62 | } 63 | 64 | JSON(w, http.StatusOK, map[string]interface{}{ 65 | "success": true, 66 | }) 67 | } 68 | 69 | // PeerGetMeshData GET /api/v1/mesh/data 70 | func (api *API) PeerGetMeshData(w http.ResponseWriter, r *http.Request) { 71 | JSON(w, http.StatusOK, api.Peer.Data()) 72 | } 73 | 74 | // PeerSetMeshData POST /api/v1/mesh/data 75 | func (api *API) PeerSetMeshData(w http.ResponseWriter, r *http.Request) { 76 | var newData map[string]interface{} 77 | 78 | if api.Peer.ForceDisabled == true { 79 | api.Peer.Advertise(false) 80 | JSON(w, http.StatusOK, map[string]interface{}{ 81 | "success": true, // this should be changed later when pwnagotchi can handle pwngrid being force advertise disabled 82 | }) 83 | return 84 | } 85 | 86 | body, err := io.ReadAll(r.Body) 87 | if err != nil { 88 | ERROR(w, http.StatusUnprocessableEntity, err) 89 | return 90 | } 91 | 92 | log.Debug("%s", body) 93 | 94 | if err = json.Unmarshal(body, &newData); err != nil { 95 | ERROR(w, http.StatusUnprocessableEntity, err) 96 | return 97 | } 98 | 99 | // this makes sure that the pwngrid server receives advertisements 100 | api.Client.SetData(map[string]interface{}{ 101 | "advertisement": newData, 102 | }) 103 | 104 | // update mesh advertisement data 105 | api.Peer.SetData(newData) 106 | 107 | JSON(w, http.StatusOK, map[string]interface{}{ 108 | "success": true, 109 | }) 110 | } 111 | -------------------------------------------------------------------------------- /cmd/pwngrid/vars.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "flag" 5 | 6 | "github.com/evilsocket/islazy/log" 7 | "github.com/jayofelony/pwngrid/api" 8 | "github.com/jayofelony/pwngrid/crypto" 9 | "github.com/jayofelony/pwngrid/mesh" 10 | ) 11 | 12 | var ( 13 | debug = false 14 | ver = false 15 | wait = false 16 | inbox = false 17 | del = false 18 | unread = false 19 | clear = false 20 | whoami = false 21 | generate = false 22 | loop = false 23 | nodb = false 24 | loopPeriod = 30 25 | receiver = "" 26 | message = "" 27 | output = "" 28 | page = 1 29 | id = 0 30 | address = "0.0.0.0:8666" 31 | env = ".env" 32 | iface = "wlan0mon" 33 | keysPath = "" 34 | peersPath = "/root/peers" 35 | keys = (*crypto.KeyPair)(nil) 36 | router = (*mesh.Router)(nil) 37 | peer = (*mesh.Peer)(nil) 38 | server = (*api.API)(nil) 39 | cpuProfile = "" 40 | memProfile = "" 41 | Endpoint = "https://api.opwngrid.xyz/api/v1" 42 | advertise = true 43 | Hostname = "" 44 | ) 45 | 46 | func init() { 47 | flag.BoolVar(&ver, "version", ver, "Print version and exit.") 48 | flag.BoolVar(&debug, "debug", debug, "Enable debug logs.") 49 | flag.BoolVar(&nodb, "no-db", debug, "Don't fail if database connection can't be enstablished.") 50 | flag.StringVar(&log.Output, "log", log.Output, "Log file path or empty for standard output.") 51 | flag.StringVar(&address, "address", address, "API address.") 52 | flag.StringVar(&env, "env", env, "Load .env from.") 53 | 54 | flag.StringVar(&keysPath, "keys", keysPath, "If set, will load RSA keys from this folder and start in peer mode.") 55 | flag.BoolVar(&generate, "generate", generate, "Generate an RSA keypair if it doesn't exist yet.") 56 | flag.BoolVar(&wait, "wait", wait, "Wait for keys to be generated.") 57 | flag.IntVar(&api.ClientTimeout, "client-timeout", api.ClientTimeout, "Timeout in seconds for requests to the server when in peer mode.") 58 | flag.StringVar(&api.ClientTokenFile, "client-token", api.ClientTokenFile, "File where to store the API token.") 59 | 60 | flag.StringVar(&iface, "iface", iface, "Monitor interface to use for mesh advertising.") 61 | flag.StringVar(&peersPath, "peers", peersPath, "path to save historical information of met peers.") 62 | flag.IntVar(&mesh.SignalingPeriod, "signaling-period", mesh.SignalingPeriod, "Period in milliseconds for mesh signaling frames.") 63 | 64 | flag.BoolVar(&whoami, "whoami", whoami, "Prints the public key fingerprint and exit.") 65 | flag.BoolVar(&inbox, "inbox", inbox, "Show inbox.") 66 | flag.BoolVar(&loop, "loop", loop, "Keep refreshing and showing inbox.") 67 | flag.IntVar(&loopPeriod, "loop-period", loopPeriod, "Period in seconds to refresh the inbox.") 68 | flag.StringVar(&receiver, "send", receiver, "Receiver unit fingerprint.") 69 | flag.StringVar(&message, "message", message, "Message body or file path if prefixed by @.") 70 | flag.StringVar(&output, "output", output, "Write message body to this file instead of the standard output.") 71 | flag.BoolVar(&del, "delete", del, "Delete the specified message.") 72 | flag.BoolVar(&unread, "unread", unread, "Unread the specified message.") 73 | flag.BoolVar(&clear, "clear", unread, "Delete all messages of the given page of the inbox.") 74 | flag.IntVar(&page, "page", page, "Inbox page.") 75 | flag.IntVar(&id, "id", id, "Message id.") 76 | 77 | flag.StringVar(&cpuProfile, "cpu-profile", cpuProfile, "Generate CPU profile to this file.") 78 | flag.StringVar(&memProfile, "mem-profile", cpuProfile, "Generate memory profile to this file.") 79 | 80 | flag.StringVar(&Endpoint, "endpoint", Endpoint, "Pass which endpoint pwngrid should be using.") 81 | flag.BoolVar(&advertise, "advertise", advertise, "Advertise?") 82 | flag.StringVar(&Hostname, "hostname", Hostname, "Pass hostname to pwngrid, makes it so it wont read os.hostname()") 83 | } 84 | -------------------------------------------------------------------------------- /mesh/packet_muxer.go: -------------------------------------------------------------------------------- 1 | package mesh 2 | 3 | import ( 4 | "fmt" 5 | "github.com/evilsocket/islazy/async" 6 | "github.com/evilsocket/islazy/log" 7 | "github.com/gopacket/gopacket" 8 | "github.com/gopacket/gopacket/pcap" 9 | "strings" 10 | "time" 11 | ) 12 | 13 | const ( 14 | // ErrIfaceNotUp Ugly, but gopacket folks are not exporting pcap errors, so ... 15 | // ref. https://github.com/gopacket/gopacket/blob/96986c90e3e5c7e01deed713ff8058e357c0c047/pcap/pcap.go#L281 16 | ErrIfaceNotUp = "Interface Not Up" 17 | ) 18 | 19 | var ( 20 | SnapLength = 65536 21 | ReadTimeout = 100 22 | ) 23 | 24 | type PacketCallback func(pkt gopacket.Packet) 25 | 26 | type PacketMuxer struct { 27 | iface string 28 | filter string 29 | handle *pcap.Handle 30 | source *gopacket.PacketSource 31 | channel chan gopacket.Packet 32 | queue *async.WorkQueue 33 | stop chan struct{} 34 | 35 | onPacket PacketCallback 36 | } 37 | 38 | func dummyPacketCallback(pkt gopacket.Packet) { 39 | 40 | } 41 | 42 | func NewPacketMuxer(iface, filter string, workers int) (mux *PacketMuxer, err error) { 43 | mux = &PacketMuxer{ 44 | iface: iface, 45 | filter: filter, 46 | stop: make(chan struct{}), 47 | onPacket: dummyPacketCallback, 48 | } 49 | 50 | for retry := 0; ; retry++ { 51 | inactiveHandle, err := pcap.NewInactiveHandle(iface) 52 | if err != nil { 53 | return nil, fmt.Errorf("error while opening interface %s: %s", iface, err) 54 | } 55 | defer inactiveHandle.CleanUp() 56 | 57 | if err = inactiveHandle.SetRFMon(true); err != nil { 58 | log.Warning("error while setting interface %s in monitor mode: %s", iface, err) 59 | } 60 | 61 | if err = inactiveHandle.SetSnapLen(SnapLength); err != nil { 62 | return nil, fmt.Errorf("error while settng span len: %s", err) 63 | } 64 | /* 65 | * We don't want to pcap.BlockForever otherwise pcap_close(handle) 66 | * could hang waiting for a timeout to expire ... 67 | */ 68 | readTimeout := time.Duration(ReadTimeout) * time.Millisecond 69 | if err = inactiveHandle.SetTimeout(readTimeout); err != nil { 70 | return nil, fmt.Errorf("error while setting timeout: %s", err) 71 | } else if mux.handle, err = inactiveHandle.Activate(); err != nil { 72 | if retry == 0 && err.Error() == ErrIfaceNotUp { 73 | log.Info("interface %s is down, bringing it up ...", iface) 74 | if err := ActivateInterface(iface); err != nil { 75 | return nil, err 76 | } 77 | continue 78 | } 79 | return nil, fmt.Errorf("error while activating handle: %s", err) 80 | } 81 | 82 | if filter != "" { 83 | if err := mux.handle.SetBPFFilter(filter); err != nil { 84 | return nil, fmt.Errorf("error setting BPF filter '%s': %v", filter, err) 85 | } 86 | } 87 | 88 | break 89 | } 90 | 91 | mux.source = gopacket.NewPacketSource(mux.handle, mux.handle.LinkType()) 92 | mux.channel = mux.source.Packets() 93 | mux.queue = async.NewQueue(workers, func(arg async.Job) { 94 | mux.onPacket(arg.(gopacket.Packet)) 95 | }) 96 | 97 | return mux, nil 98 | } 99 | 100 | func (mux *PacketMuxer) OnPacket(cb PacketCallback) { 101 | mux.onPacket = cb 102 | } 103 | 104 | func (mux *PacketMuxer) Write(data []byte) error { 105 | var err error 106 | for attempt := 0; attempt < 5; attempt++ { 107 | if err = mux.handle.WritePacketData(data); err == nil { 108 | return nil 109 | } else if strings.Contains(err.Error(), "temporarily unavailable") { 110 | log.Debug("resource temporarily unavailable when sending data") 111 | // if it's the last attempt this will set err to nil as we can't really 112 | // do a lot about this case, otherwise it'll wait 200ms before the next 113 | // attempt is made. 114 | err = nil 115 | if attempt < 5 { 116 | time.Sleep(200 * time.Millisecond) 117 | } 118 | } else { 119 | return nil 120 | } 121 | } 122 | return err 123 | } 124 | 125 | func (mux *PacketMuxer) Start() { 126 | go func() { 127 | log.Debug("packet muxer started (iface:%s filter:%s)", mux.iface, mux.filter) 128 | for { 129 | select { 130 | case packet := <-mux.channel: 131 | mux.queue.Add(async.Job(packet)) 132 | case <-mux.stop: 133 | return 134 | } 135 | } 136 | }() 137 | } 138 | 139 | func (mux *PacketMuxer) Stop() { 140 | log.Debug("stopping packet muxer ...") 141 | mux.stop <- struct{}{} 142 | mux.queue.WaitDone() 143 | log.Debug("packet muxer stopped") 144 | } 145 | -------------------------------------------------------------------------------- /mesh/routing.go: -------------------------------------------------------------------------------- 1 | package mesh 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "github.com/evilsocket/islazy/log" 8 | "github.com/gopacket/gopacket" 9 | "github.com/gopacket/gopacket/layers" 10 | "github.com/jayofelony/pwngrid/wifi" 11 | "sync" 12 | "time" 13 | ) 14 | 15 | var ( 16 | Workers = 0 17 | PeerTTL = 1800 18 | Peers = sync.Map{} 19 | ) 20 | 21 | func dummyPeerActivityCallback(ident string, peer *Peer) {} 22 | 23 | type PeerActivityCallback func(ident string, peer *Peer) 24 | 25 | type Router struct { 26 | local *Peer 27 | mux *PacketMuxer 28 | onNewPeer PeerActivityCallback 29 | onPeerLost PeerActivityCallback 30 | memory *Memory 31 | } 32 | 33 | func StartRouting(iface string, peersPath string, local *Peer) (*Router, error) { 34 | err, memory := MemoryFromPath(peersPath) 35 | if err != nil { 36 | return nil, err 37 | } 38 | 39 | filter := fmt.Sprintf("type mgt subtype beacon and ether src %s", wifi.SignatureAddrStr) 40 | mux, err := NewPacketMuxer(iface, filter, Workers) 41 | if err != nil { 42 | return nil, err 43 | } 44 | 45 | router := &Router{ 46 | mux: mux, 47 | local: local, 48 | memory: memory, 49 | onNewPeer: dummyPeerActivityCallback, 50 | onPeerLost: dummyPeerActivityCallback, 51 | } 52 | mux.OnPacket(router.onPacket) 53 | mux.Start() 54 | 55 | log.Info("started beacon discovery and message routing (%d known peers)", router.memory.Size()) 56 | 57 | go router.peersPruner() 58 | 59 | return router, nil 60 | } 61 | 62 | func (router *Router) Memory() []*Peer { 63 | return router.memory.List() 64 | } 65 | 66 | func (router *Router) MemoryOf(fingerprint string) *Peer { 67 | return router.memory.Of(fingerprint) 68 | } 69 | 70 | func (router *Router) OnNewPeer(cb PeerActivityCallback) { 71 | router.onNewPeer = cb 72 | } 73 | 74 | func (router *Router) OnPeerLost(cb PeerActivityCallback) { 75 | router.onPeerLost = cb 76 | } 77 | 78 | func (router *Router) peersPruner() { 79 | period := time.Duration(500) * time.Millisecond 80 | tick := time.NewTicker(period) 81 | 82 | log.Debug("peers pruner started with a %s period", period) 83 | 84 | for range tick.C { 85 | stale := map[string]*Peer{} 86 | 87 | Peers.Range(func(key, value interface{}) bool { 88 | ident := key.(string) 89 | peer := value.(*Peer) 90 | inactive := peer.InactiveFor() 91 | if int(inactive) > PeerTTL { 92 | stale[ident] = peer 93 | } 94 | return true 95 | }) 96 | 97 | for ident, peer := range stale { 98 | Peers.Delete(ident) 99 | router.onPeerLost(ident, peer) 100 | } 101 | } 102 | } 103 | 104 | func (router *Router) newPeer(ident string, peer *Peer) { 105 | Peers.Store(ident, peer) 106 | router.onNewPeer(ident, peer) 107 | } 108 | 109 | func (router *Router) onPeerAdvertisement(pkt gopacket.Packet, radio *layers.RadioTap, dot11 *layers.Dot11) { 110 | err, payload := wifi.Unpack(pkt, radio, dot11) 111 | if err != nil { 112 | log.Debug("%v", err) 113 | return 114 | } 115 | 116 | advData := make(map[string]interface{}) 117 | if err := json.Unmarshal(payload, &advData); err != nil { 118 | log.Debug("error decoding payload '%s': %v", payload, err) 119 | return 120 | } 121 | 122 | ident, ok := advData["identity"] 123 | if !ok { 124 | log.Debug("error parsing identity from payload '%s'", payload) 125 | return 126 | } 127 | 128 | var peer *Peer 129 | 130 | _peer, existing := Peers.Load(ident) 131 | if existing { 132 | peer = _peer.(*Peer) 133 | if err := peer.Update(radio, dot11, advData); err != nil { 134 | log.Warning("error updating peer %s: %v", peer.ID(), err) 135 | } else if err := router.memory.Track(ident.(string), peer); err != nil { 136 | log.Error("error saving peer encounter for %s: %v", ident, err) 137 | } 138 | } else { 139 | if peer, err = NewPeer(radio, dot11, advData); err != nil { 140 | log.Debug("error creating peer: %v", err) 141 | return 142 | } else if err := router.memory.Track(ident.(string), peer); err != nil { 143 | log.Error("error saving peer encounter for %s: %v", ident, err) 144 | } else { 145 | router.newPeer(ident.(string), peer) 146 | } 147 | } 148 | 149 | } 150 | 151 | func (router *Router) onPacket(pkt gopacket.Packet) { 152 | if ok, radio, dot11 := wifi.Parse(pkt); ok && dot11.ChecksumValid() { 153 | src := dot11.Address3 154 | dst := dot11.Address1 155 | if !bytes.Equal(src, router.local.SessionID) { 156 | if bytes.Equal(dst, wifi.BroadcastAddr) { 157 | router.onPeerAdvertisement(pkt, radio, dot11) 158 | } else { 159 | // log.Debug("ignoring message %x > %x", src, dst) 160 | } 161 | } 162 | } 163 | } 164 | -------------------------------------------------------------------------------- /crypto/keypair.go: -------------------------------------------------------------------------------- 1 | package crypto 2 | 3 | import ( 4 | "crypto/rand" 5 | "crypto/rsa" 6 | "crypto/x509" 7 | "encoding/pem" 8 | "fmt" 9 | "github.com/evilsocket/islazy/fs" 10 | "github.com/evilsocket/islazy/log" 11 | "os" 12 | "path" 13 | "strings" 14 | ) 15 | 16 | type KeyPair struct { 17 | Path string 18 | Bits int 19 | PrivatePath string 20 | Private *rsa.PrivateKey 21 | PrivatePEM []byte 22 | PublicPath string 23 | Public *rsa.PublicKey 24 | PublicPEM []byte 25 | // sha256 of PublicSSH 26 | Fingerprint []byte 27 | FingerprintHex string 28 | } 29 | 30 | func pubKeyToPEM(key *rsa.PublicKey) ([]byte, error) { 31 | bytes, err := x509.MarshalPKIXPublicKey(key) 32 | if err != nil { 33 | return nil, err 34 | } 35 | return pem.EncodeToMemory( 36 | &pem.Block{ 37 | Type: "RSA PUBLIC KEY", 38 | Bytes: bytes, 39 | }, 40 | ), nil 41 | } 42 | 43 | func FromPublicPEM(pubPEM string) (pair *KeyPair, err error) { 44 | block, _ := pem.Decode([]byte(pubPEM)) 45 | if block == nil { 46 | return nil, fmt.Errorf("failed to parse PEM block containing the public key") 47 | } 48 | 49 | pub, err := x509.ParsePKIXPublicKey(block.Bytes) 50 | if err != nil { 51 | return nil, err 52 | } 53 | 54 | pair = &KeyPair{} 55 | ok := false 56 | 57 | if pair.Public, ok = pub.(*rsa.PublicKey); !ok { 58 | return nil, fmt.Errorf("not an RSA key") 59 | } 60 | 61 | return pair, pair.setupPublic() 62 | } 63 | 64 | func PrivatePath(keysPath string) string { 65 | return path.Join(keysPath, "id_rsa") 66 | } 67 | 68 | func Load(keysPath string) (pair *KeyPair, err error) { 69 | privFile := PrivatePath(keysPath) 70 | pair = &KeyPair{ 71 | Path: keysPath, 72 | PrivatePath: privFile, 73 | PublicPath: privFile + ".pub", 74 | } 75 | return pair, pair.Load() 76 | } 77 | 78 | func KeysExist(keysPath string) bool { 79 | return fs.Exists(keysPath) && fs.Exists(PrivatePath(keysPath)) 80 | } 81 | 82 | func LoadOrCreate(keysPath string, bits int) (pair *KeyPair, err error) { 83 | privFile := PrivatePath(keysPath) 84 | pair = &KeyPair{ 85 | Path: keysPath, 86 | Bits: bits, 87 | PrivatePath: privFile, 88 | PublicPath: privFile + ".pub", 89 | } 90 | 91 | if !fs.Exists(pair.PrivatePath) { 92 | if !fs.Exists(keysPath) { 93 | log.Debug("creating %s", keysPath) 94 | if err := os.MkdirAll(keysPath, os.ModePerm); err != nil { 95 | return nil, fmt.Errorf("could not create %s: %v", keysPath, err) 96 | } 97 | } 98 | log.Info("%s not found, generating keypair ...", pair.PrivatePath) 99 | 100 | if pair.Private, err = rsa.GenerateKey(rand.Reader, bits); err != nil { 101 | return nil, fmt.Errorf("could not generate private key: %v", err) 102 | } 103 | pair.Public = &pair.Private.PublicKey 104 | 105 | if err = pair.Save(); err != nil { 106 | return nil, fmt.Errorf("could not save keypair: %v", err) 107 | } 108 | } else if err = pair.Load(); err != nil { 109 | return nil, fmt.Errorf("could not load keypair: %v", err) 110 | } 111 | 112 | return pair, nil 113 | } 114 | 115 | func (pair *KeyPair) setupPublic() (err error) { 116 | if pair.PublicPEM, err = pubKeyToPEM(pair.Public); err != nil { 117 | return fmt.Errorf("failed converting public key to PEM: %v", err) 118 | } 119 | 120 | cleanPEM := strings.TrimRight(string(pair.PublicPEM), "\n") 121 | 122 | hash := Hasher.New() 123 | hash.Write([]byte(cleanPEM)) 124 | 125 | pair.Fingerprint = hash.Sum(nil) 126 | pair.FingerprintHex = fmt.Sprintf("%02x", pair.Fingerprint) 127 | 128 | return nil 129 | } 130 | 131 | func (pair *KeyPair) Save() (err error) { 132 | prvKeyBytes := x509.MarshalPKCS1PrivateKey(pair.Private) 133 | pair.PrivatePEM = pem.EncodeToMemory( 134 | &pem.Block{ 135 | Type: "RSA PRIVATE KEY", 136 | Bytes: prvKeyBytes, 137 | }, 138 | ) 139 | 140 | if err = os.WriteFile(pair.PrivatePath, pair.PrivatePEM, os.ModePerm); err != nil { 141 | return 142 | } 143 | 144 | log.Debug("%s created", pair.PrivatePath) 145 | 146 | if err = pair.setupPublic(); err != nil { 147 | return err 148 | } 149 | 150 | err = os.WriteFile(pair.PublicPath, pair.PublicPEM, os.ModePerm) 151 | 152 | log.Debug("%s created", pair.PublicPath) 153 | return 154 | } 155 | 156 | func (pair *KeyPair) Load() (err error) { 157 | log.Debug("reading %s ...", pair.PrivatePath) 158 | if pair.PrivatePEM, err = os.ReadFile(pair.PrivatePath); err != nil { 159 | return 160 | } 161 | 162 | block, _ := pem.Decode(pair.PrivatePEM) 163 | if block == nil { 164 | return fmt.Errorf("failed decoding PEM from %s", pair.PrivatePath) 165 | } 166 | 167 | if pair.Private, err = x509.ParsePKCS1PrivateKey(block.Bytes); err != nil { 168 | return fmt.Errorf("failed parsing %s: %v", pair.PrivatePath, err) 169 | } 170 | 171 | pair.Public = &pair.Private.PublicKey 172 | return pair.setupPublic() 173 | } 174 | -------------------------------------------------------------------------------- /cmd/pwngrid/inbox.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "fmt" 5 | "github.com/evilsocket/islazy/log" 6 | "github.com/evilsocket/islazy/tui" 7 | "github.com/jayofelony/pwngrid/api" 8 | "os" 9 | "os/exec" 10 | "runtime" 11 | "time" 12 | ) 13 | 14 | func clearScreen() { 15 | var what []string 16 | if runtime.GOOS == "windows" { 17 | what = []string{"cmd", "/c", "cls"} 18 | } else { 19 | what = []string{"clear", ""} 20 | } 21 | cmd := exec.Command(what[0], what[1:]...) 22 | cmd.Stdout = os.Stdout 23 | err := cmd.Run() 24 | if err != nil { 25 | return 26 | } 27 | } 28 | 29 | func showInbox(server *api.API, box map[string]interface{}) { 30 | messages := box["messages"].([]interface{}) 31 | numMessages := len(messages) 32 | 33 | if numMessages > 0 { 34 | if clear { 35 | log.Info("clearing %d messages", numMessages) 36 | for _, m := range messages { 37 | msg := m.(map[string]interface{}) 38 | msgID := int(msg["id"].(float64)) 39 | log.Info("deleting message %d ...", msgID) 40 | if _, err := server.Client.MarkInboxMessage(msgID, "deleted"); err != nil { 41 | log.Error("%v", err) 42 | } 43 | } 44 | } else { 45 | records := box["records"].(float64) 46 | pages := box["pages"].(float64) 47 | columns := []string{ 48 | "ID", 49 | "Date", 50 | "Sender", 51 | } 52 | rows := [][]string{} 53 | for _, m := range messages { 54 | var row []string 55 | msg := m.(map[string]interface{}) 56 | 57 | t, err := time.Parse(time.RFC3339, msg["created_at"].(string)) 58 | if err != nil { 59 | panic(err) 60 | } 61 | 62 | row = []string{ 63 | fmt.Sprintf("%d", int(msg["id"].(float64))), 64 | t.Format("02 January 2006, 3:04 PM"), 65 | fmt.Sprintf("%s@%s", msg["sender_name"], msg["sender"]), 66 | } 67 | 68 | if msg["seen_at"] != nil { 69 | for i := range row { 70 | row[i] = tui.Dim(row[i]) 71 | } 72 | } 73 | 74 | rows = append(rows, row) 75 | } 76 | 77 | fmt.Println() 78 | tui.Table(os.Stdout, columns, rows) 79 | fmt.Println() 80 | 81 | fmt.Printf("%d of %d (page %d of %d)", numMessages, int(records), page, int(pages)) 82 | } 83 | } else { 84 | fmt.Println() 85 | fmt.Println(tui.Dim("Inbox is empty.")) 86 | } 87 | 88 | fmt.Println() 89 | } 90 | 91 | func showMessage(msg map[string]interface{}) { 92 | t, err := time.Parse(time.RFC3339, msg["created_at"].(string)) 93 | if err != nil { 94 | panic(err) 95 | } 96 | 97 | fmt.Println() 98 | fmt.Printf("From: %s@%s\n", msg["sender_name"], msg["sender"]) 99 | fmt.Printf("Date: %s\n\n", t.Format("02 January 2006, 3:04 PM")) 100 | if output == "" { 101 | fmt.Printf("%s\n", msg["data"]) 102 | fmt.Println() 103 | } else if err := os.WriteFile(output, msg["data"].([]byte), os.ModePerm); err != nil { 104 | log.Fatal("error writing to %s: %v", output, err) 105 | } else { 106 | log.Info("%s written", output) 107 | } 108 | } 109 | 110 | func sendMessage() { 111 | var err error 112 | 113 | // send a message 114 | var raw []byte 115 | if message == "" { 116 | log.Fatal("-message can not be empty") 117 | } else if message[0] == '@' { 118 | log.Info("reading %s ...", message[1:]) 119 | if raw, err = os.ReadFile(message[1:]); err != nil { 120 | log.Fatal("error reading %s: %v", message[1:], err) 121 | } 122 | } else { 123 | raw = []byte(message) 124 | } 125 | 126 | if status, err := server.SendMessage(receiver, raw); err != nil { 127 | log.Fatal("%d %v", status, err) 128 | } else { 129 | log.Info("message sent") 130 | } 131 | } 132 | 133 | func doInbox(server *api.API) { 134 | if receiver != "" { 135 | sendMessage() 136 | } else if inbox { 137 | // just show the inbox 138 | if id == 0 { 139 | log.Info("fetching inbox ...") 140 | if box, err := server.Client.Inbox(page); err != nil { 141 | log.Fatal("%v", err) 142 | } else { 143 | showInbox(server, box) 144 | } 145 | } else if del { 146 | log.Info("deleting message %d ...", id) 147 | if _, err := server.Client.MarkInboxMessage(id, "deleted"); err != nil { 148 | log.Fatal("%v", err) 149 | } 150 | } else if unread { 151 | log.Info("marking message %d as unread ...", id) 152 | if _, err := server.Client.MarkInboxMessage(id, "unseen"); err != nil { 153 | log.Fatal("%v", err) 154 | } 155 | } else { 156 | log.Info("fetching message %d ...", id) 157 | 158 | if msg, status, err := server.InboxMessage(id); err != nil { 159 | log.Fatal("%d %v", status, err) 160 | } else { 161 | showMessage(msg) 162 | _, _ = server.Client.MarkInboxMessage(id, "seen") 163 | } 164 | } 165 | } 166 | } 167 | 168 | func inboxMain() { 169 | if inbox { 170 | doInbox(server) 171 | if loop { 172 | ticker := time.NewTicker(time.Duration(loopPeriod) * time.Second) 173 | for _ = range ticker.C { 174 | clearScreen() 175 | doInbox(server) 176 | } 177 | } 178 | os.Exit(0) 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /cmd/pwngrid/setup.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "os/signal" 6 | "runtime/pprof" 7 | "time" 8 | 9 | "github.com/evilsocket/islazy/fs" 10 | "github.com/evilsocket/islazy/log" 11 | "github.com/jayofelony/pwngrid/api" 12 | "github.com/jayofelony/pwngrid/crypto" 13 | "github.com/jayofelony/pwngrid/mesh" 14 | "github.com/jayofelony/pwngrid/models" 15 | "github.com/jayofelony/pwngrid/utils" 16 | "github.com/jayofelony/pwngrid/version" 17 | "github.com/joho/godotenv" 18 | ) 19 | 20 | func cleanup() { 21 | if cpuProfile != "" { 22 | log.Info("writing CPU profile to %s ...", cpuProfile) 23 | pprof.StopCPUProfile() 24 | } 25 | 26 | if memProfile != "" { 27 | log.Info("writing memory profile to %s ...", memProfile) 28 | f, err := os.Create(memProfile) 29 | if err != nil { 30 | log.Fatal("%v", err) 31 | } 32 | defer func() { 33 | if err := f.Close(); err != nil { 34 | panic(err) 35 | } 36 | }() 37 | if err := pprof.WriteHeapProfile(f); err != nil { 38 | panic(err) 39 | } 40 | } 41 | } 42 | 43 | func setupCore() { 44 | c := make(chan os.Signal, 1) 45 | signal.Notify(c, os.Interrupt) 46 | go func() { 47 | for sig := range c { 48 | log.Warning("received signal %v", sig) 49 | cleanup() 50 | os.Exit(0) 51 | } 52 | }() 53 | 54 | if cpuProfile != "" { 55 | f, err := os.Create(cpuProfile) 56 | if err != nil { 57 | log.Fatal("%v", err) 58 | } 59 | if err := pprof.StartCPUProfile(f); err != nil { 60 | panic(err) 61 | } 62 | } 63 | 64 | if debug { 65 | log.Level = log.DEBUG 66 | } else { 67 | log.Level = log.INFO 68 | } 69 | log.OnFatal = log.ExitOnFatal 70 | } 71 | 72 | func waitForKeys() { 73 | privPath := crypto.PrivatePath(keysPath) 74 | for { 75 | if !fs.Exists(privPath) { 76 | log.Debug("waiting for %s ...", privPath) 77 | time.Sleep(1 * time.Second) 78 | } else { 79 | // give it a moment to finish disk sync 80 | time.Sleep(2 * time.Second) 81 | log.Info("%s found", privPath) 82 | break 83 | } 84 | } 85 | } 86 | 87 | func setupMesh() { 88 | var err error 89 | peer = mesh.MakeLocalPeer(utils.Hostname(), keys, advertise) 90 | if !advertise { 91 | return //this probably doesn't work 92 | } 93 | 94 | if err = peer.StartAdvertising(iface); err != nil { 95 | log.Fatal("error while starting signaling: %v", err) 96 | } 97 | if router, err = mesh.StartRouting(iface, peersPath, peer); err != nil { 98 | log.Fatal("%v", err) 99 | } else { 100 | router.OnNewPeer(func(ident string, peer *mesh.Peer) { 101 | log.Info("detected new peer %s on channel %d", peer.ID(), peer.Channel) 102 | }) 103 | router.OnPeerLost(func(ident string, peer *mesh.Peer) { 104 | log.Info("peer %s lost (inactive for %fs)", peer.ID(), peer.InactiveFor()) 105 | }) 106 | } 107 | log.Info("peer %s signaling is ready", peer.ID()) 108 | } 109 | 110 | func setupDB() { 111 | if err := godotenv.Load(env); err != nil { 112 | log.Fatal("%v", err) 113 | } 114 | if err := models.Setup(); err != nil { 115 | if nodb { 116 | log.Warning("%v", err) 117 | } else { 118 | log.Fatal("%v", err) 119 | } 120 | } 121 | } 122 | 123 | func setupMode() string { 124 | var err error 125 | 126 | // in case -inbox was not explicitly passed 127 | if receiver != "" || loop == true || id > 0 { 128 | inbox = true 129 | } 130 | 131 | // for inbox actions, set the keys to the default path if empty 132 | if (whoami || inbox) && keysPath == "" { 133 | keysPath = "/etc/pwnagotchi/" 134 | } 135 | 136 | // generate keypair 137 | if generate { 138 | if keysPath == "" { 139 | log.Fatal("no -keys path specified") 140 | } else if crypto.KeysExist(keysPath) { 141 | log.Fatal("keypair already exists in %s", keysPath) 142 | } 143 | 144 | if _, err = crypto.LoadOrCreate(keysPath, 4096); err != nil { 145 | log.Fatal("error generating RSA keypair: %v", err) 146 | } else { 147 | log.Info("keypair saved to %s", keysPath) 148 | } 149 | os.Exit(0) 150 | } 151 | 152 | mode := "peer" 153 | // if keys have been passed explicitly, or one of the inbox actions 154 | // has been specified, we're running on the unit 155 | // if keysPath != "" { 156 | // mode = "peer" 157 | // } 158 | 159 | log.Info("pwngrid v%s starting in %s mode ...", version.Version, mode) 160 | 161 | // wait for keys to be generated 162 | if wait { 163 | waitForKeys() 164 | } 165 | // load the keys 166 | if keys, err = crypto.Load(keysPath); err != nil { 167 | log.Fatal("error while loading keys from %s: %v", keysPath, err) 168 | } 169 | // print identity and exit 170 | if whoami { 171 | if Endpoint == "https://api.opwngrid.xyz/api/v1" { 172 | log.Info("https://opwngrid.xyz/search/%s", keys.FingerprintHex) 173 | } else { 174 | log.Info("https://pwnagotchi.ai/pwnfile/#!%s", keys.FingerprintHex) 175 | } 176 | os.Exit(0) 177 | } 178 | // only start mesh signaling if this is not an inbox action 179 | if !inbox { 180 | setupMesh() 181 | } 182 | 183 | // set up the proper routes for either server or peer mode 184 | err, server = api.Setup(keys, peer, router, Endpoint, Hostname) 185 | if err != nil { 186 | log.Fatal("%v", err) 187 | } 188 | 189 | return mode 190 | } 191 | -------------------------------------------------------------------------------- /models/unit.go: -------------------------------------------------------------------------------- 1 | package models 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | "github.com/golang-jwt/jwt/v5" 7 | "os" 8 | "reflect" 9 | "sync" 10 | "time" 11 | 12 | "github.com/evilsocket/islazy/log" 13 | ) 14 | 15 | const ( 16 | TokenTTL = time.Minute * 30 17 | CacheTTL = time.Minute * 120 18 | ) 19 | 20 | type cachedCounter struct { 21 | Time time.Time 22 | Count int 23 | } 24 | 25 | var ( 26 | cache = make(map[uint]*cachedCounter) 27 | cacheLock = sync.Mutex{} 28 | ) 29 | 30 | type Unit struct { 31 | ID uint `gorm:"primary_key" json:"-"` 32 | CreatedAt time.Time `json:"enrolled_at"` 33 | UpdatedAt time.Time `json:"updated_at"` 34 | DeletedAt *time.Time `sql:"index" json:"-"` 35 | Address string `gorm:"size:50;not null" json:"-"` 36 | Country string `gorm:"size:10" json:"country"` 37 | Name string `gorm:"size:255;not null" json:"name"` 38 | Fingerprint string `gorm:"size:255;not null;unique" json:"fingerprint"` 39 | PublicKey string `gorm:"size:10000;not null" json:"public_key"` 40 | Token string `gorm:"size:10000;not null" json:"-"` 41 | Data string `gorm:"size:10000;not null" json:"data"` 42 | 43 | AccessPoints []AccessPoint `gorm:"foreignkey:UnitID" json:"-"` 44 | 45 | Inbox []Message `gorm:"foreignkey:ReceiverID" json:"-"` 46 | Sent []Message `gorm:"foreignkey:SenderID" json:"-"` 47 | } 48 | 49 | func (u Unit) Identity() string { 50 | return fmt.Sprintf("%s@%s", u.Name, u.Fingerprint) 51 | } 52 | 53 | func (u Unit) FindAccessPoint(essid, bssid string) *AccessPoint { 54 | var ap AccessPoint 55 | 56 | if err := db.Where("unit_id = ? AND name = ? AND mac = ?", u.ID, essid, bssid).Take(&ap).Error; err != nil { 57 | if err := db.Where("unit_id = ? AND mac = ?", u.ID, bssid).Take(&ap).Error; err != nil { 58 | return nil 59 | } 60 | } 61 | 62 | return &ap 63 | } 64 | 65 | func (u *Unit) updateToken() error { 66 | claims := jwt.MapClaims{} 67 | claims["authorized"] = true 68 | claims["unit_id"] = u.ID 69 | claims["unit_ident"] = u.Identity() 70 | claims["expires_at"] = time.Now().Add(TokenTTL).Format(time.RFC3339) 71 | token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) 72 | signed, err := token.SignedString([]byte(os.Getenv("API_SECRET"))) 73 | if err != nil { 74 | return err 75 | } 76 | u.Token = signed 77 | return nil 78 | } 79 | 80 | func (u *Unit) UpdateWith(enroll EnrollmentRequest) error { 81 | prevData := map[string]interface{}{} 82 | 83 | if u.Data != "" { 84 | if err := json.Unmarshal([]byte(u.Data), &prevData); err != nil { 85 | log.Warning("error parsing previous data: %v", err) 86 | log.Debug("%s", u.Data) 87 | } 88 | } 89 | 90 | // only replace sent values 91 | for key, obj := range enroll.Data { 92 | set := true 93 | if key == "session" { 94 | if session, ok := obj.(map[string]interface{}); !ok { 95 | set = false 96 | log.Warning("corrupted session (first level): %v", obj) 97 | } else if epochs, found := session["epochs"]; !found { 98 | set = false 99 | log.Warning("corrupted session (no epochs): %v", obj) 100 | } else if num, ok := epochs.(float64); !ok { 101 | set = false 102 | log.Warning("corrupted session (epochs type %v): %v", reflect.TypeOf(epochs), obj) 103 | } else if num == 0 { 104 | // do not update with empty sessions 105 | set = false 106 | } 107 | } 108 | 109 | if set { 110 | prevData[key] = obj 111 | } 112 | } 113 | 114 | newData, err := json.Marshal(prevData) 115 | if err != nil { 116 | return err 117 | } 118 | 119 | if u.Name != enroll.Name { 120 | log.Info("unit %s changed name: %s -> %s", u.Identity(), u.Name, enroll.Name) 121 | } 122 | 123 | u.Name = enroll.Name 124 | u.Address = enroll.Address 125 | u.Country = enroll.Country 126 | u.Data = string(newData) 127 | 128 | return db.Save(u).Error 129 | } 130 | 131 | type unitJSON struct { 132 | EnrolledAt time.Time `json:"enrolled_at"` 133 | UpdatedAt time.Time `json:"updated_at"` 134 | Country string `json:"country"` 135 | Name string `json:"name"` 136 | Fingerprint string `json:"fingerprint"` 137 | PublicKey string `json:"public_key"` 138 | Data map[string]interface{} `json:"data"` 139 | Networks int `json:"networks"` 140 | } 141 | 142 | func (u *Unit) apCounter() int { 143 | cacheLock.Lock() 144 | defer cacheLock.Unlock() 145 | 146 | count := -1 147 | if cnt, found := cache[u.ID]; found && time.Since(cnt.Time) < CacheTTL { 148 | count = cnt.Count 149 | } 150 | 151 | if count == -1 { 152 | count = db.Model(u).Association("AccessPoints").Count() 153 | cache[u.ID] = &cachedCounter{ 154 | Time: time.Now(), 155 | Count: count, 156 | } 157 | } 158 | 159 | return count 160 | } 161 | 162 | func (u *Unit) MarshalJSON() ([]byte, error) { 163 | doc := unitJSON{ 164 | EnrolledAt: u.CreatedAt, 165 | UpdatedAt: u.UpdatedAt, 166 | Country: u.Country, 167 | Name: u.Name, 168 | Fingerprint: u.Fingerprint, 169 | PublicKey: u.PublicKey, 170 | Data: map[string]interface{}{}, 171 | Networks: u.apCounter(), 172 | } 173 | 174 | if u.Data != "" { 175 | if err := json.Unmarshal([]byte(u.Data), &doc.Data); err != nil { 176 | return nil, err 177 | } 178 | } 179 | 180 | return json.Marshal(doc) 181 | } 182 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA= 2 | filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4= 3 | github.com/PuerkitoBio/goquery v1.5.1/go.mod h1:GsLWisAFVj4WgDibEWF4pvYnkVQBpKBKeU+7zCJoLcc= 4 | github.com/andybalholm/cascadia v1.1.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9PqHO0sqidkEA4Y= 5 | github.com/biezhi/gorm-paginator/pagination v0.0.0-20250219022659-7f61c90f8f21 h1:0SSFN9kbB2MkoEQv6u5H6svD1lPPWQV6tLx5qfLGXZk= 6 | github.com/biezhi/gorm-paginator/pagination v0.0.0-20250219022659-7f61c90f8f21/go.mod h1:Y/N4aF7p+Med/9ivVSsGBc8xOs8BGptUVBCY3k4KFCY= 7 | github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd h1:83Wprp6ROGeiHFAP8WJdI2RoxALQYgdllERc3N5N2DM= 8 | github.com/denisenkom/go-mssqldb v0.0.0-20191124224453-732737034ffd/go.mod h1:xbL0rPBG9cCiLr28tMa8zpbdarY27NDyej4t/EjAShU= 9 | github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5 h1:Yzb9+7DPaBjB8zlTR87/ElzFsnQfuHnVUVqpZZIcV5Y= 10 | github.com/erikstmartin/go-testdb v0.0.0-20160219214506-8d10e4a1bae5/go.mod h1:a2zkGnVExMxdzMo3M0Hi/3sEU+cWnZpSni0O6/Yb/P0= 11 | github.com/evilsocket/islazy v1.11.0 h1:B5w6uuS6ki6iDG+aH/RFeoMb8ijQh/pGabewqp2UeJ0= 12 | github.com/evilsocket/islazy v1.11.0/go.mod h1:muYH4x5MB5YRdkxnrOtrXLIBX6LySj1uFIqys94LKdo= 13 | github.com/go-chi/chi/v5 v5.2.3 h1:WQIt9uxdsAbgIYgid+BpYc+liqQZGMHRaUwp0JUcvdE= 14 | github.com/go-chi/chi/v5 v5.2.3/go.mod h1:L2yAIGWB3H+phAw1NxKwWM+7eUH/lU8pOMm5hHcoops= 15 | github.com/go-chi/cors v1.2.2 h1:Jmey33TE+b+rB7fT8MUy1u0I4L+NARQlK6LhzKPSyQE= 16 | github.com/go-chi/cors v1.2.2/go.mod h1:sSbTewc+6wYHBBCW7ytsFSn836hqM7JxpglAy2Vzc58= 17 | github.com/go-sql-driver/mysql v1.5.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= 18 | github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo= 19 | github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU= 20 | github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo= 21 | github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= 22 | github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe h1:lXe2qZdvpiX5WZkZR4hgp4KJVfY3nMkvmwbVkpv1rVY= 23 | github.com/golang-sql/civil v0.0.0-20190719163853-cb61b32ac6fe/go.mod h1:8vg3r2VgvsThLBIFL93Qb5yWzgyZWhEmBwUJWevAkK0= 24 | github.com/gopacket/gopacket v1.5.0 h1:9s9fcSUVKFlRV97B77Bq9XNV3ly2gvvsneFMQUGjc+M= 25 | github.com/gopacket/gopacket v1.5.0/go.mod h1:i3NaGaqfoWKAr1+g7qxEdWsmfT+MXuWkAe9+THv8LME= 26 | github.com/jinzhu/gorm v1.9.2/go.mod h1:Vla75njaFJ8clLU1W44h34PjIkijhjHIYnZxMqCdxqo= 27 | github.com/jinzhu/gorm v1.9.16 h1:+IyIjPEABKRpsu/F8OvDPy9fyQlgsg2luMV2ZIH5i5o= 28 | github.com/jinzhu/gorm v1.9.16/go.mod h1:G3LB3wezTOWM2ITLzPxEXgSkOXAntiLHS7UdBefADcs= 29 | github.com/jinzhu/inflection v0.0.0-20180308033659-04140366298a/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 30 | github.com/jinzhu/inflection v1.0.0 h1:K317FqzuhWc8YvSVlFMCCUb36O/S9MCKRDI7QkRKD/E= 31 | github.com/jinzhu/inflection v1.0.0/go.mod h1:h+uFLlag+Qp1Va5pdKtLDYj+kHp5pxUVkryuEj+Srlc= 32 | github.com/jinzhu/now v1.0.1/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 33 | github.com/jinzhu/now v1.1.5 h1:/o9tlHleP7gOFmsnYNz3RGnqzefHA47wQpKrrdTIwXQ= 34 | github.com/jinzhu/now v1.1.5/go.mod h1:d3SSVoowX0Lcu0IBviAWJpolVfI5UJVZZ7cO71lE/z8= 35 | github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= 36 | github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= 37 | github.com/lib/pq v1.1.1 h1:sJZmqHoEaY7f+NPP8pgLB/WxulyR3fewgCM2qaSlBb4= 38 | github.com/lib/pq v1.1.1/go.mod h1:5WUZQaWbwv1U+lTReE5YruASi9Al49XbQIvNi/34Woo= 39 | github.com/mattn/go-sqlite3 v1.14.0/go.mod h1:JIl7NbARA7phWnGvh0LKTyg7S9BA+6gx71ShQilpsus= 40 | github.com/mattn/go-sqlite3 v1.14.22 h1:2gZY6PC6kBnID23Tichd1K+Z0oS6nE/XwU+Vz/5o4kU= 41 | github.com/mattn/go-sqlite3 v1.14.22/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= 42 | golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 43 | golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= 44 | golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd h1:GGJVjV8waZKRHrgwvtH66z9ZGVurTD1MT0n1Bb+q4aM= 45 | golang.org/x/crypto v0.0.0-20191205180655-e7c4368fe9dd/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= 46 | golang.org/x/net v0.0.0-20180218175443-cbe0f9307d01/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= 47 | golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= 48 | golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= 49 | golang.org/x/net v0.0.0-20200324143707-d3edc9973b7e/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= 50 | golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY= 51 | golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E= 52 | golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= 53 | golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 54 | golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= 55 | golang.org/x/sys v0.39.0 h1:CvCKL8MeisomCi6qNZ+wbb0DN9E5AATixKsvNtMoMFk= 56 | golang.org/x/sys v0.39.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= 57 | golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= 58 | golang.org/x/text v0.32.0 h1:ZD01bjUt1FQ9WJ0ClOL5vxgxOI/sVCNgX1YtKwcY0mU= 59 | golang.org/x/text v0.32.0/go.mod h1:o/rUWzghvpD5TXrTIBuJU77MTaN0ljMWE47kxGJQ7jY= 60 | gorm.io/gorm v1.31.1 h1:7CA8FTFz/gRfgqgpeKIBcervUn3xSyPUmr6B2WXJ7kg= 61 | gorm.io/gorm v1.31.1/go.mod h1:XyQVbO2k6YkOis7C2437jSit3SsDK72s7n7rsSHd+Gs= 62 | -------------------------------------------------------------------------------- /api/peer_inbox.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/base64" 5 | "errors" 6 | "fmt" 7 | "github.com/evilsocket/islazy/log" 8 | "github.com/go-chi/chi/v5" 9 | "github.com/jayofelony/pwngrid/crypto" 10 | "github.com/jayofelony/pwngrid/models" 11 | "io" 12 | "net/http" 13 | "strconv" 14 | ) 15 | 16 | var ( 17 | ErrEmptyMessage = errors.New("empty message body") 18 | ErrSenderNotFound = errors.New("sender not found") 19 | ) 20 | 21 | // PeerGetInbox /api/v1/inbox/ 22 | func (api *API) PeerGetInbox(w http.ResponseWriter, r *http.Request) { 23 | page, err := pageNum(r) 24 | if err != nil { 25 | ERROR(w, http.StatusUnprocessableEntity, err) 26 | return 27 | } 28 | 29 | obj, err := api.Client.Inbox(page) 30 | if err != nil { 31 | ERROR(w, http.StatusUnprocessableEntity, err) 32 | return 33 | } 34 | 35 | JSON(w, http.StatusOK, obj) 36 | } 37 | 38 | func (api *API) InboxMessage(id int) (map[string]interface{}, int, error) { 39 | message, err := api.Client.InboxMessage(id) 40 | if err != nil { 41 | return nil, http.StatusUnprocessableEntity, err 42 | } 43 | 44 | sender, found := message["sender"] 45 | if !found { 46 | return nil, http.StatusNotFound, ErrSenderNotFound 47 | } 48 | 49 | fingerprint, ok := sender.(string) 50 | if !ok { 51 | return nil, http.StatusUnprocessableEntity, ErrSenderNotFound 52 | } 53 | 54 | unit, err := api.Client.Unit(fingerprint) 55 | if err != nil { 56 | return nil, http.StatusNotFound, err 57 | } 58 | 59 | srcKeys, err := crypto.FromPublicPEM(unit["public_key"].(string)) 60 | if err != nil { 61 | return nil, http.StatusUnprocessableEntity, err 62 | } 63 | 64 | data, err := base64.StdEncoding.DecodeString(message["data"].(string)) 65 | if err != nil { 66 | return nil, http.StatusUnprocessableEntity, err 67 | } 68 | 69 | signature, err := base64.StdEncoding.DecodeString(message["signature"].(string)) 70 | if err != nil { 71 | return nil, http.StatusUnprocessableEntity, err 72 | } 73 | 74 | log.Info("verifying message from %s ...", fingerprint) 75 | 76 | if err := srcKeys.VerifyMessage(data, signature); err != nil { 77 | return nil, http.StatusUnprocessableEntity, err 78 | } 79 | 80 | log.Info("decrypting message from %s ...", fingerprint) 81 | 82 | clearText, err := api.Keys.Decrypt(data) 83 | if err != nil { 84 | return nil, http.StatusUnprocessableEntity, err 85 | } 86 | 87 | message["data"] = clearText 88 | 89 | return message, 0, nil 90 | } 91 | 92 | // /api/v1/inbox/ 93 | func (api *API) PeerGetInboxMessage(w http.ResponseWriter, r *http.Request) { 94 | msgIDParam := chi.URLParam(r, "msg_id") 95 | msgID, err := strconv.Atoi(msgIDParam) 96 | if err != nil { 97 | ERROR(w, http.StatusUnprocessableEntity, err) 98 | return 99 | } 100 | 101 | message, status, err := api.InboxMessage(msgID) 102 | if err != nil { 103 | ERROR(w, status, err) 104 | return 105 | } 106 | 107 | JSON(w, http.StatusOK, message) 108 | } 109 | 110 | // /api/v1/inbox// 111 | func (api *API) PeerMarkInboxMessage(w http.ResponseWriter, r *http.Request) { 112 | markAs := chi.URLParam(r, "mark") 113 | msgIDParam := chi.URLParam(r, "msg_id") 114 | msgID, err := strconv.Atoi(msgIDParam) 115 | if err != nil { 116 | ERROR(w, http.StatusUnprocessableEntity, err) 117 | return 118 | } 119 | 120 | obj, err := api.Client.MarkInboxMessage(msgID, markAs) 121 | if err != nil { 122 | ERROR(w, http.StatusUnprocessableEntity, err) 123 | return 124 | } 125 | 126 | JSON(w, http.StatusOK, obj) 127 | } 128 | 129 | func (api *API) SendMessage(fingerprint string, cleartext []byte) (int, error) { 130 | unit, err := api.Client.Unit(fingerprint) 131 | if err != nil { 132 | return http.StatusNotFound, err 133 | } 134 | 135 | unitKeys, err := crypto.FromPublicPEM(unit["public_key"].(string)) 136 | if err != nil { 137 | log.Error("error parsing public key of %s: %v", fingerprint, err) 138 | return http.StatusUnprocessableEntity, err 139 | } 140 | 141 | messageBody, err := api.Keys.EncryptFor(cleartext, unitKeys.Public) 142 | if err != nil { 143 | log.Error("error encrypting message for %s: %v", fingerprint, err) 144 | return http.StatusUnprocessableEntity, err 145 | } 146 | 147 | messageSize := len(messageBody) 148 | if messageSize == 0 { 149 | return http.StatusUnprocessableEntity, ErrEmptyMessage 150 | } else if messageSize > models.MessageDataMaxSize { 151 | err := fmt.Errorf("max message signature size is %d", models.MessageSignatureMaxSize) 152 | return http.StatusUnprocessableEntity, err 153 | } 154 | 155 | log.Info("signing encrypted message of %d bytes for %s ...", messageSize, fingerprint) 156 | 157 | signature, err := api.Keys.SignMessage(messageBody) 158 | if err != nil { 159 | log.Error("%v", err) 160 | return http.StatusUnprocessableEntity, err 161 | } 162 | 163 | msg := Message{ 164 | Signature: base64.StdEncoding.EncodeToString(signature), 165 | Data: base64.StdEncoding.EncodeToString(messageBody), 166 | } 167 | 168 | if err := api.Client.SendMessageTo(fingerprint, msg); err != nil { 169 | log.Error("%v", err) 170 | return http.StatusUnprocessableEntity, err 171 | } 172 | 173 | return 0, nil 174 | } 175 | 176 | // POST /api/v1/unit//inbox 177 | func (api *API) PeerSendMessageTo(w http.ResponseWriter, r *http.Request) { 178 | cleartextMessage, err := io.ReadAll(r.Body) 179 | if err != nil { 180 | log.Error("error reading request body: %v", err) 181 | ERROR(w, http.StatusUnprocessableEntity, err) 182 | return 183 | } 184 | 185 | fingerprint := chi.URLParam(r, "fingerprint") 186 | status, err := api.SendMessage(fingerprint, cleartextMessage) 187 | if err != nil { 188 | ERROR(w, status, err) 189 | return 190 | } 191 | 192 | JSON(w, http.StatusOK, map[string]interface{}{ 193 | "success": true, 194 | }) 195 | } 196 | -------------------------------------------------------------------------------- /api/unit_inbox.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "encoding/base64" 5 | "encoding/json" 6 | "errors" 7 | "github.com/evilsocket/islazy/log" 8 | "github.com/go-chi/chi/v5" 9 | "github.com/jayofelony/pwngrid/crypto" 10 | "github.com/jayofelony/pwngrid/models" 11 | "io" 12 | "net/http" 13 | "strconv" 14 | "time" 15 | ) 16 | 17 | var ( 18 | ErrRecNotFound = errors.New("recipient not found") 19 | ErrMessageNotFound = errors.New("message not found") 20 | ErrInvalidKey = errors.New("invalid public key") 21 | ErrInvalidSignature = errors.New("can't verify signature") 22 | ErrDecoding = errors.New("error decoding data") 23 | ) 24 | 25 | func (api *API) GetInbox(w http.ResponseWriter, r *http.Request) { 26 | unit := Authenticate(w, r) 27 | if unit == nil { 28 | return 29 | } 30 | 31 | page, err := pageNum(r) 32 | if err != nil { 33 | ERROR(w, http.StatusUnprocessableEntity, err) 34 | return 35 | } 36 | 37 | messages, total, pages := unit.GetPagedInbox(page) 38 | JSON(w, http.StatusOK, map[string]interface{}{ 39 | "records": total, 40 | "pages": pages, 41 | "messages": messages, 42 | }) 43 | } 44 | 45 | // we need this because the models.Message structure doesn't not export data and signature for fast listing. 46 | type fullMessage struct { 47 | ID uint `json:"id"` 48 | CreatedAt time.Time `json:"created_at"` 49 | UpdatedAt time.Time `json:"updated_at"` 50 | DeletedAt *time.Time `json:"deleted_at"` 51 | SeenAt *time.Time `json:"seen_at"` 52 | Sender string `json:"sender"` 53 | SenderName string `json:"sender_name"` 54 | Data string `json:"data"` 55 | Signature string `json:"signature"` 56 | } 57 | 58 | func (api *API) GetInboxMessage(w http.ResponseWriter, r *http.Request) { 59 | unit := Authenticate(w, r) 60 | if unit == nil { 61 | return 62 | } 63 | 64 | msgIDParam := chi.URLParam(r, "msg_id") 65 | msgID, err := strconv.Atoi(msgIDParam) 66 | if err != nil { 67 | ERROR(w, http.StatusUnprocessableEntity, err) 68 | return 69 | } else if message := unit.GetInboxMessage(msgID); message == nil { 70 | ERROR(w, http.StatusNotFound, ErrMessageNotFound) 71 | return 72 | } else { 73 | JSON(w, http.StatusOK, fullMessage{ 74 | ID: message.ID, 75 | CreatedAt: message.CreatedAt, 76 | UpdatedAt: message.UpdatedAt, 77 | DeletedAt: message.DeletedAt, 78 | SeenAt: message.SeenAt, 79 | Sender: message.Sender, 80 | SenderName: message.SenderName, 81 | Data: message.Data, 82 | Signature: message.Signature, 83 | }) 84 | } 85 | } 86 | 87 | func (api *API) MarkInboxMessage(w http.ResponseWriter, r *http.Request) { 88 | unit := Authenticate(w, r) 89 | if unit == nil { 90 | return 91 | } 92 | 93 | now := time.Now() 94 | markAs := chi.URLParam(r, "mark") 95 | msgIDParam := chi.URLParam(r, "msg_id") 96 | msgID, err := strconv.Atoi(msgIDParam) 97 | 98 | if err != nil { 99 | ERROR(w, http.StatusUnprocessableEntity, err) 100 | return 101 | } else if message := unit.GetInboxMessage(msgID); message == nil { 102 | ERROR(w, http.StatusNotFound, ErrMessageNotFound) 103 | return 104 | } else if markAs == "seen" { 105 | if err := models.UpdateFields(message, map[string]interface{}{"seen_at": &now}).Error; err != nil { 106 | ERROR(w, http.StatusUnprocessableEntity, err) 107 | return 108 | } 109 | } else if markAs == "unseen" { 110 | if err := models.UpdateFields(message, map[string]interface{}{"seen_at": nil}).Error; err != nil { 111 | ERROR(w, http.StatusUnprocessableEntity, err) 112 | return 113 | } 114 | } else if markAs == "deleted" { 115 | if err := models.UpdateFields(message, map[string]interface{}{"deleted_at": &now}).Error; err != nil { 116 | ERROR(w, http.StatusUnprocessableEntity, err) 117 | return 118 | } 119 | } else if markAs == "restored" { 120 | if err := models.UpdateFields(message, map[string]interface{}{"deleted_at": nil}).Error; err != nil { 121 | ERROR(w, http.StatusUnprocessableEntity, err) 122 | return 123 | } 124 | } else { 125 | ERROR(w, http.StatusNotFound, ErrEmpty) 126 | return 127 | } 128 | 129 | JSON(w, http.StatusOK, map[string]bool{ 130 | "success": true, 131 | }) 132 | } 133 | 134 | func (api *API) SendMessageTo(w http.ResponseWriter, r *http.Request) { 135 | // authenticate source unit 136 | srcUnit := Authenticate(w, r) 137 | if srcUnit == nil { 138 | return 139 | } 140 | 141 | // get dest unit by fingerprint 142 | dstUnitFingerprint := chi.URLParam(r, "fingerprint") 143 | dstUnit := models.FindUnitByFingerprint(dstUnitFingerprint) 144 | if dstUnit == nil { 145 | ERROR(w, http.StatusNotFound, ErrRecNotFound) 146 | return 147 | } 148 | 149 | // read the message and signature from the source unit 150 | client := clientIP(r) 151 | body, err := io.ReadAll(r.Body) 152 | if err != nil { 153 | ERROR(w, http.StatusUnprocessableEntity, err) 154 | return 155 | } 156 | 157 | var message Message 158 | if err = json.Unmarshal(body, &message); err != nil { 159 | log.Debug("error while decoding message from %s: %v", srcUnit.Identity(), err) 160 | log.Debug("%s", body) 161 | ERROR(w, http.StatusUnprocessableEntity, err) 162 | return 163 | } 164 | 165 | if err := models.ValidateMessage(message.Data, message.Signature); err != nil { 166 | log.Warning("client %s sent a broken message structure: %v", srcUnit.Identity(), err) 167 | ERROR(w, http.StatusUnprocessableEntity, err) 168 | return 169 | } 170 | 171 | // parse source unit key 172 | srcKeys, err := crypto.FromPublicPEM(srcUnit.PublicKey) 173 | if err != nil { 174 | log.Warning("error decoding key from %s: %v", srcUnit.Identity(), err) 175 | log.Debug("%s", srcUnit.PublicKey) 176 | ERROR(w, http.StatusUnprocessableEntity, ErrInvalidKey) 177 | return 178 | } 179 | 180 | // decode data, signature and verify SIGN(SHA256(data)) 181 | data, err := base64.StdEncoding.DecodeString(message.Data) 182 | if err != nil { 183 | log.Warning("error decoding message from %s: %v", srcUnit.Identity(), err) 184 | log.Debug("%s", message.Data) 185 | ERROR(w, http.StatusUnprocessableEntity, ErrDecoding) 186 | return 187 | } 188 | 189 | signature, err := base64.StdEncoding.DecodeString(message.Signature) 190 | if err != nil { 191 | log.Warning("error decoding signature from %s: %v", srcUnit.Identity(), err) 192 | log.Debug("%s", message.Signature) 193 | ERROR(w, http.StatusUnprocessableEntity, ErrDecoding) 194 | return 195 | } 196 | 197 | if err := srcKeys.VerifyMessage(data, signature); err != nil { 198 | log.Warning("error verifying signature from %s: %v", srcUnit.Identity(), err) 199 | log.Debug("%s", message.Signature) 200 | ERROR(w, http.StatusUnprocessableEntity, ErrInvalidSignature) 201 | return 202 | } 203 | 204 | msg := models.Message{ 205 | SenderID: srcUnit.ID, 206 | Sender: srcUnit.Fingerprint, 207 | SenderName: srcUnit.Name, 208 | ReceiverID: dstUnit.ID, 209 | Data: message.Data, 210 | Signature: message.Signature, 211 | } 212 | 213 | if err := models.Create(&msg).Error; err != nil { 214 | log.Warning("error creating msg %v from %s: %v", msg, client, err) 215 | ERROR(w, http.StatusInternalServerError, ErrEmpty) 216 | return 217 | } 218 | 219 | JSON(w, http.StatusOK, map[string]interface{}{ 220 | "success": true, 221 | }) 222 | } 223 | -------------------------------------------------------------------------------- /api/client.go: -------------------------------------------------------------------------------- 1 | package api 2 | 3 | import ( 4 | "bytes" 5 | "encoding/base64" 6 | "encoding/json" 7 | "fmt" 8 | "io" 9 | "net" 10 | "net/http" 11 | "os" 12 | "sync" 13 | "time" 14 | 15 | "github.com/evilsocket/islazy/log" 16 | "github.com/jayofelony/pwngrid/crypto" 17 | "github.com/jayofelony/pwngrid/models" 18 | "github.com/jayofelony/pwngrid/utils" 19 | ) 20 | 21 | var ( 22 | ClientTimeout = 60 23 | ClientKeepalive = 30 24 | ClientTokenFile = "/tmp/pwngrid-api-enrollment.json" 25 | Endpoint = "" 26 | ) 27 | 28 | //const ( 29 | // Endpoint 30 | //) 31 | 32 | type Client struct { 33 | sync.Mutex 34 | 35 | cli *http.Client 36 | keys *crypto.KeyPair 37 | token string 38 | tokenAt time.Time 39 | data map[string]interface{} 40 | hostname string 41 | } 42 | 43 | func NewClient(keys *crypto.KeyPair, endpoint string, hostname string) *Client { 44 | 45 | t := &http.Transport{ 46 | Dial: (&net.Dialer{ 47 | Timeout: time.Duration(ClientTimeout) * time.Second, 48 | KeepAlive: time.Duration(ClientKeepalive) * time.Second, 49 | }).Dial, 50 | TLSHandshakeTimeout: time.Duration(ClientTimeout) * time.Second, 51 | ResponseHeaderTimeout: time.Duration(ClientTimeout) * time.Second, 52 | ExpectContinueTimeout: 4 * time.Second, 53 | } 54 | 55 | cli := &Client{ 56 | cli: &http.Client{ 57 | Transport: t, 58 | Timeout: time.Duration(ClientTimeout) * time.Second, 59 | }, 60 | keys: keys, 61 | data: make(map[string]interface{}), 62 | hostname: hostname, 63 | } 64 | 65 | Endpoint = endpoint 66 | 67 | if info, err := os.Stat(ClientTokenFile); err == nil { 68 | if time.Since(info.ModTime()) < models.TokenTTL { 69 | log.Debug("loading token from %s ...", ClientTokenFile) 70 | var data map[string]interface{} 71 | if raw, err := os.ReadFile(ClientTokenFile); err == nil { 72 | if err := json.Unmarshal(raw, &data); err == nil { 73 | cli.token = data["token"].(string) 74 | cli.tokenAt = info.ModTime() 75 | log.Debug("token: %s", cli.token) 76 | } else { 77 | log.Warning("error decoding %s: %v", ClientTokenFile, err) 78 | } 79 | } else { 80 | log.Warning("error reading %s: %v", ClientTokenFile, err) 81 | } 82 | } else { 83 | log.Debug("token in %s is expired", ClientTokenFile) 84 | } 85 | } 86 | 87 | return cli 88 | } 89 | 90 | func (c *Client) enroll() error { 91 | 92 | hostname := c.hostname 93 | if hostname == "" { 94 | hostname = utils.Hostname() 95 | } 96 | identity := fmt.Sprintf("%s@%s", hostname, c.keys.FingerprintHex) 97 | 98 | log.Debug("refreshing api token as %s ...", identity) 99 | 100 | signature, err := c.keys.SignMessage([]byte(identity)) 101 | if err != nil { 102 | return err 103 | } 104 | 105 | signature64 := base64.StdEncoding.EncodeToString(signature) 106 | pubKeyPEM64 := base64.StdEncoding.EncodeToString(c.keys.PublicPEM) 107 | 108 | log.Debug("SIGN(%s) = %s", identity, signature64) 109 | 110 | enrollment := map[string]interface{}{ 111 | "identity": identity, 112 | "public_key": pubKeyPEM64, 113 | "signature": signature64, 114 | "data": c.data, 115 | } 116 | 117 | obj, err := c.request("POST", "/unit/enroll", enrollment, false) 118 | if err != nil { 119 | return err 120 | } 121 | 122 | c.tokenAt = time.Now() 123 | c.token = obj["token"].(string) 124 | log.Debug("new token: %s", c.token) 125 | 126 | if raw, err := json.Marshal(obj); err == nil { 127 | log.Debug("saving token to %s ...", ClientTokenFile) 128 | if err = os.WriteFile(ClientTokenFile, raw, 0644); err != nil { 129 | log.Warning("error saving token to %s: %v", ClientTokenFile, err) 130 | } 131 | } else { 132 | log.Warning("error encoding token: %v", err) 133 | } 134 | 135 | return nil 136 | } 137 | 138 | func (c *Client) request(method string, path string, data interface{}, auth bool) (map[string]interface{}, error) { 139 | url := fmt.Sprintf("%s%s", Endpoint, path) 140 | err := (error)(nil) 141 | started := time.Now() 142 | defer func() { 143 | if err == nil { 144 | log.Debug("%s %s (%s)", method, url, time.Since(started)) 145 | } else { 146 | log.Error("%s %s (%s) %v", method, url, time.Since(started), err) 147 | } 148 | }() 149 | 150 | buf := new(bytes.Buffer) 151 | if data != nil { 152 | if err = json.NewEncoder(buf).Encode(data); err != nil { 153 | return nil, err 154 | } 155 | } 156 | 157 | req, err := http.NewRequest(method, url, buf) 158 | if err != nil { 159 | return nil, err 160 | } 161 | 162 | if auth { 163 | if time.Since(c.tokenAt) >= models.TokenTTL { 164 | if err := c.enroll(); err != nil { 165 | return nil, err 166 | } 167 | } 168 | req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.token)) 169 | } 170 | 171 | res, err := c.cli.Do(req) 172 | if err != nil { 173 | return nil, err 174 | } 175 | body, err := io.ReadAll(res.Body) 176 | if err != nil { 177 | return nil, err 178 | } 179 | 180 | var obj map[string]interface{} 181 | if err = json.Unmarshal(body, &obj); err != nil { 182 | log.Debug(fmt.Sprintf("Error Unmarshalling json body from request: %v", body)) 183 | return nil, err 184 | } 185 | 186 | if res.StatusCode == 401 { 187 | if err := c.enroll(); err != nil { 188 | log.Warning("error token expired during operation: %v", err) 189 | return nil, err 190 | } 191 | log.Warning("token expired, re-enroll success") 192 | } 193 | 194 | if res.StatusCode != 200 { 195 | err = fmt.Errorf("%d %s", res.StatusCode, obj["error"]) 196 | } 197 | 198 | return obj, err 199 | } 200 | 201 | func (c *Client) SetData(newData map[string]interface{}) map[string]interface{} { 202 | c.Lock() 203 | defer c.Unlock() 204 | 205 | for key, val := range newData { 206 | if val == nil { 207 | delete(c.data, key) 208 | } else { 209 | c.data[key] = val 210 | } 211 | } 212 | 213 | return c.data 214 | } 215 | 216 | func (c *Client) Data() map[string]interface{} { 217 | c.Lock() 218 | defer c.Unlock() 219 | return c.data 220 | } 221 | 222 | func (c *Client) Request(method string, path string, data interface{}, auth bool) (map[string]interface{}, error) { 223 | c.Lock() 224 | defer c.Unlock() 225 | return c.request(method, path, data, auth) 226 | } 227 | 228 | func (c *Client) Get(path string, auth bool) (map[string]interface{}, error) { 229 | return c.Request("GET", path, nil, auth) 230 | } 231 | 232 | func (c *Client) Post(path string, what interface{}, auth bool) (map[string]interface{}, error) { 233 | return c.Request("POST", path, what, auth) 234 | } 235 | 236 | func (c *Client) PagedUnits(page int) (map[string]interface{}, error) { 237 | return c.Get(fmt.Sprintf("/units/?p=%d", page), false) 238 | } 239 | 240 | func (c *Client) Unit(fingerprint string) (map[string]interface{}, error) { 241 | return c.Get(fmt.Sprintf("/unit/%s", fingerprint), false) 242 | } 243 | 244 | func (c *Client) ReportAP(report interface{}) (map[string]interface{}, error) { 245 | return c.Post("/unit/report/ap", report, true) 246 | } 247 | 248 | func (c *Client) Inbox(page int) (map[string]interface{}, error) { 249 | return c.Get(fmt.Sprintf("/unit/inbox/?p=%d", page), true) 250 | } 251 | 252 | func (c *Client) InboxMessage(id int) (map[string]interface{}, error) { 253 | return c.Get(fmt.Sprintf("/unit/inbox/%d", id), true) 254 | } 255 | 256 | func (c *Client) MarkInboxMessage(id int, mark string) (map[string]interface{}, error) { 257 | return c.Get(fmt.Sprintf("/unit/inbox/%d/%s", id, mark), true) 258 | } 259 | 260 | func (c *Client) SendMessageTo(fingerprint string, msg Message) error { 261 | _, err := c.Post(fmt.Sprintf("/unit/%s/inbox", fingerprint), msg, true) 262 | return err 263 | } 264 | -------------------------------------------------------------------------------- /mesh/peer.go: -------------------------------------------------------------------------------- 1 | package mesh 2 | 3 | import ( 4 | "bytes" 5 | "crypto/rand" 6 | "encoding/base64" 7 | "encoding/binary" 8 | "encoding/json" 9 | "fmt" 10 | "net" 11 | "reflect" 12 | "regexp" 13 | "strings" 14 | "sync" 15 | "time" 16 | 17 | "github.com/evilsocket/islazy/log" 18 | "github.com/gopacket/gopacket/layers" 19 | "github.com/jayofelony/pwngrid/crypto" 20 | "github.com/jayofelony/pwngrid/version" 21 | "github.com/jayofelony/pwngrid/wifi" 22 | ) 23 | 24 | var ( 25 | SignalingPeriod = 300 26 | 27 | fingValidator = regexp.MustCompile("^[a-fA-F0-9]{64}$") 28 | ) 29 | 30 | type SessionID []byte 31 | 32 | type Peer struct { 33 | sync.Mutex 34 | 35 | MetAt time.Time // first time met 36 | DetectedAt time.Time // first time detected on this session 37 | SeenAt time.Time // last time detected on this session 38 | PrevSeenAt time.Time // if we met this unit before, this is the last time it's been seen 39 | Encounters uint64 40 | Channel int 41 | RSSI int 42 | SessionID SessionID 43 | SessionIDStr string 44 | Keys *crypto.KeyPair 45 | AdvData sync.Map 46 | AdvPeriod int 47 | 48 | advEnabled bool 49 | ForceDisabled bool 50 | 51 | mux *PacketMuxer 52 | stop chan struct{} 53 | } 54 | 55 | func MakeLocalPeer(name string, keys *crypto.KeyPair, advertise bool) *Peer { 56 | now := time.Now() 57 | peer := &Peer{ 58 | DetectedAt: now, 59 | SeenAt: now, 60 | PrevSeenAt: now, 61 | SessionID: make([]byte, 6), 62 | Keys: keys, 63 | AdvData: sync.Map{}, 64 | AdvPeriod: SignalingPeriod, 65 | stop: make(chan struct{}), 66 | advEnabled: false, 67 | ForceDisabled: false, 68 | } 69 | if !advertise { 70 | peer.ForceDisabled = true 71 | } 72 | 73 | if _, err := rand.Read(peer.SessionID); err != nil { 74 | panic(err) 75 | } 76 | 77 | parts := make([]string, 6) 78 | for idx, byte := range peer.SessionID { 79 | parts[idx] = fmt.Sprintf("%02x", byte) 80 | } 81 | peer.SessionIDStr = strings.Join(parts, ":") 82 | 83 | peer.AdvData.Store("name", name) 84 | peer.AdvData.Store("identity", keys.FingerprintHex) 85 | peer.AdvData.Store("session_id", peer.SessionIDStr) 86 | peer.AdvData.Store("grid_version", version.Version) 87 | 88 | peer.AdvData.Range(func(key, value interface{}) bool { 89 | log.Debug("local.adv.%s = %s", key, value) 90 | return true 91 | }) 92 | 93 | return peer 94 | } 95 | 96 | func (peer *Peer) Advertise(enabled bool) { 97 | peer.Lock() 98 | defer peer.Unlock() 99 | diff := peer.advEnabled != enabled 100 | peer.advEnabled = enabled 101 | if diff { 102 | if enabled { 103 | log.Info("peer advertisement enabled") 104 | } else { 105 | log.Info("peer advertisement disabled") 106 | } 107 | } 108 | } 109 | 110 | func NewPeer(radiotap *layers.RadioTap, dot11 *layers.Dot11, adv map[string]interface{}) (peer *Peer, err error) { 111 | now := time.Now() 112 | peer = &Peer{ 113 | DetectedAt: now, 114 | SeenAt: now, 115 | PrevSeenAt: now, 116 | Channel: wifi.Freq2Chan(channelFromRadioTap(radiotap)), 117 | RSSI: dbmFromRadioTap(radiotap), 118 | SessionID: SessionID(dot11.Address3), 119 | AdvData: sync.Map{}, 120 | } 121 | 122 | parts := make([]string, 6) 123 | for idx, byte := range peer.SessionID { 124 | parts[idx] = fmt.Sprintf("%02x", byte) 125 | } 126 | peer.SessionIDStr = strings.Join(parts, ":") 127 | 128 | // parse the fingerprint, the signature and the public key 129 | fingerprint, found := adv["identity"].(string) 130 | if !found { 131 | return nil, fmt.Errorf("peer %x is not advertising any identity", peer.SessionID) 132 | } else if !fingValidator.MatchString(fingerprint) { 133 | return nil, fmt.Errorf("peer %x is advertising an invalid fingerprint: %s", peer.SessionID, fingerprint) 134 | } 135 | 136 | if pubKey64, found := adv["public_key"]; found { 137 | pubKey, err := base64.StdEncoding.DecodeString(pubKey64.(string)) 138 | if err != nil { 139 | return nil, fmt.Errorf("error decoding peer %s public key: %s", fingerprint, err) 140 | } 141 | 142 | peer.Keys, err = crypto.FromPublicPEM(string(pubKey)) 143 | if err != nil { 144 | return nil, fmt.Errorf("error parsing peer %s public key: %s", fingerprint, err) 145 | } 146 | 147 | // basic consistency check 148 | if peer.Keys.FingerprintHex != fingerprint { 149 | return nil, fmt.Errorf("peer %x is advertising fingerprint %s, but it should be %s", peer.SessionID, fingerprint, peer.Keys.FingerprintHex) 150 | } 151 | } else if !found { 152 | log.Debug("peer %s is not advertising any public key", fingerprint) 153 | } 154 | 155 | for key, value := range adv { 156 | peer.AdvData.Store(key, value) 157 | } 158 | 159 | return peer, nil 160 | } 161 | 162 | func (peer *Peer) Update(radio *layers.RadioTap, dot11 *layers.Dot11, adv map[string]interface{}) (err error) { 163 | peer.Lock() 164 | defer peer.Unlock() 165 | 166 | // parse the fingerprint, the signature and the public key 167 | fingerprint, found := adv["identity"].(string) 168 | if !found { 169 | return fmt.Errorf("peer %x is not advertising any identity", peer.SessionID) 170 | } 171 | 172 | // basic consistency check 173 | if peer.Keys != nil && peer.Keys.FingerprintHex != fingerprint { 174 | return fmt.Errorf("peer %x is advertising fingerprint %s, but it should be %s", peer.SessionID, fingerprint, peer.Keys.FingerprintHex) 175 | } 176 | 177 | /* 178 | No need for signature in the advertisement protocol, however: 179 | 180 | signature64, found := adv["signature"].(string) 181 | if !found { 182 | return fmt.Errorf("peer %x is not advertising any signature", peer.SessionID) 183 | } 184 | 185 | signature, err := base64.StdEncoding.DecodeString(signature64) 186 | if err != nil { 187 | return fmt.Errorf("error decoding peer %d signature: %s", peer.SessionID, err) 188 | } 189 | 190 | // the signature is SIGN(advertisement), so we need to remove the signature field and convert back to json. 191 | // NOTE: fortunately, keys will always be sorted, so we don't have to do anything in order to guarantee signature 192 | // consistency (https://stackoverflow.com/questions/18668652/how-to-produce-json-with-sorted-keys-in-go) 193 | signedMap := adv 194 | delete(signedMap, "signature") 195 | 196 | signedData, err := json.Marshal(signedMap) 197 | if err != nil { 198 | return fmt.Errorf("error packing data for signature verification: %v", err) 199 | } 200 | 201 | // verify the signature 202 | if err = peer.Keys.VerifyMessage(signedData, signature); err != nil { 203 | return fmt.Errorf("peer %x signature is invalid", peer.SessionID) 204 | } 205 | */ 206 | 207 | peer.Channel = wifi.Freq2Chan(channelFromRadioTap(radio)) 208 | peer.RSSI = dbmFromRadioTap(radio) 209 | 210 | if !bytes.Equal(peer.SessionID, dot11.Address3) { 211 | log.Info("peer %s changed session id: %x -> %x", peer.ID(), peer.SessionIDStr, dot11.Address3) 212 | copy(peer.SessionID, dot11.Address3) 213 | parts := make([]string, 6) 214 | for idx, byte := range peer.SessionID { 215 | parts[idx] = fmt.Sprintf("%02x", byte) 216 | } 217 | peer.SessionIDStr = strings.Join(parts, ":") 218 | } 219 | 220 | for key, value := range adv { 221 | peer.AdvData.Store(key, value) 222 | } 223 | 224 | return nil 225 | } 226 | 227 | func (peer *Peer) ID() string { 228 | name, _ := peer.AdvData.Load("name") 229 | ident := "???" 230 | 231 | if peer.Keys != nil { 232 | ident = peer.Keys.FingerprintHex 233 | } else if _ident, found := peer.AdvData.Load("identity"); found { 234 | ident = _ident.(string) 235 | } 236 | 237 | return fmt.Sprintf("%s@%s", name, ident) 238 | } 239 | 240 | func (peer *Peer) InactiveFor() float64 { 241 | peer.Lock() 242 | defer peer.Unlock() 243 | return time.Since(peer.DetectedAt).Seconds() 244 | } 245 | 246 | func (peer *Peer) SetData(adv map[string]interface{}) { 247 | if peer == nil { 248 | return 249 | } 250 | peer.Lock() 251 | defer peer.Unlock() 252 | 253 | for key, val := range adv { 254 | if val == nil { 255 | peer.AdvData.Delete(key) 256 | } else { 257 | peer.AdvData.Store(key, val) 258 | } 259 | } 260 | } 261 | 262 | func (peer *Peer) Data() map[string]interface{} { 263 | peer.Lock() 264 | defer peer.Unlock() 265 | return peer.dataFrame() 266 | } 267 | 268 | func (peer *Peer) dataFrame() map[string]interface{} { 269 | data := map[string]interface{}{} 270 | peer.AdvData.Range(func(key, value interface{}) bool { 271 | data[key.(string)] = value 272 | return true 273 | }) 274 | return data 275 | } 276 | 277 | func (peer *Peer) advertise() { 278 | peer.Lock() 279 | defer peer.Unlock() 280 | 281 | if peer.advEnabled { 282 | data := peer.dataFrame() 283 | 284 | data["timestamp"] = time.Now().Unix() 285 | adv, err := json.Marshal(data) 286 | if err != nil { 287 | log.Error("could not serialize advertisement data: %v", err) 288 | return 289 | } 290 | 291 | /* 292 | No need for signature in the advertisement protocol, however: 293 | 294 | // sign the advertisement 295 | signature, err := peer.Keys.SignMessage(adv) 296 | if err != nil { 297 | log.Error("error signing advertisement: %v", err) 298 | return 299 | } 300 | 301 | // add the signature to the advertisement itself and encode again 302 | data["signature"] = base64.StdEncoding.EncodeToString(signature) 303 | adv, err = json.Marshal(data) 304 | if err != nil { 305 | log.Error("could not serialize signed advertisement data: %v", err) 306 | return 307 | } 308 | 309 | log.Debug("advertising:\n%+v", data) 310 | */ 311 | 312 | err, raw := wifi.Pack( 313 | net.HardwareAddr(peer.SessionID), 314 | wifi.BroadcastAddr, 315 | adv, 316 | false) // set compression to true if using signature 317 | if err != nil { 318 | log.Error("could not encapsulate %d bytes of advertisement data: %v", len(adv), err) 319 | return 320 | } 321 | 322 | if err = peer.mux.Write(raw); err != nil { 323 | log.Error("error sending %d bytes of advertisement frame: %v", len(raw), err) 324 | } 325 | } 326 | } 327 | 328 | func (peer *Peer) StartAdvertising(iface string) (err error) { 329 | if peer.mux == nil { 330 | if peer.mux, err = NewPacketMuxer(iface, "", Workers); err != nil { 331 | return 332 | } 333 | } 334 | 335 | go func() { 336 | period := time.Duration(peer.AdvPeriod) * time.Millisecond 337 | ticker := time.NewTicker(period) 338 | 339 | log.Debug("advertiser started with a %s period", period) 340 | 341 | for { 342 | select { 343 | case _ = <-ticker.C: 344 | peer.advertise() 345 | case <-peer.stop: 346 | log.Info("advertiser stopped") 347 | return 348 | } 349 | } 350 | }() 351 | 352 | return nil 353 | } 354 | 355 | func (peer *Peer) StopAdvertising() { 356 | log.Debug("stopping advertiser ...") 357 | peer.stop <- struct{}{} 358 | } 359 | 360 | // helper: extract channel frequency (MHz) from RadioTap 361 | func channelFromRadioTap(r *layers.RadioTap) int { 362 | if r == nil { 363 | return 0 364 | } 365 | 366 | // try direct exported field via reflection (works across gopacket versions) 367 | rv := reflect.ValueOf(r) 368 | if rv.Kind() == reflect.Ptr && !rv.IsNil() { 369 | rv = rv.Elem() 370 | if rv.IsValid() && rv.Kind() == reflect.Struct { 371 | if f := rv.FieldByName("ChannelFrequency"); f.IsValid() { 372 | switch f.Kind() { 373 | case reflect.Uint16, reflect.Uint32, reflect.Uint64: 374 | return int(f.Uint()) 375 | case reflect.Int, reflect.Int32, reflect.Int64: 376 | return int(f.Int()) 377 | } 378 | } 379 | } 380 | } 381 | 382 | // fallback: scan RadioTapValues for a 2-byte little-endian frequency 383 | for _, ns := range r.RadioTapValues { 384 | sv := reflect.ValueOf(ns) 385 | if sv.Kind() == reflect.Struct { 386 | for _, name := range []string{"Data", "Value"} { 387 | if f := sv.FieldByName(name); f.IsValid() && f.Kind() == reflect.Slice && f.Type().Elem().Kind() == reflect.Uint8 { 388 | b := f.Bytes() 389 | if len(b) >= 2 { 390 | freq := int(binary.LittleEndian.Uint16(b[:2])) 391 | if freq >= 2000 && freq <= 6000 { // plausible WiFi freq 392 | return freq 393 | } 394 | } 395 | } 396 | } 397 | } 398 | } 399 | 400 | return 0 401 | } 402 | 403 | // helper: extract dBm antenna signal (RSSI) from RadioTap 404 | func dbmFromRadioTap(r *layers.RadioTap) int { 405 | if r == nil { 406 | return 0 407 | } 408 | 409 | // try direct exported field via reflection 410 | rv := reflect.ValueOf(r) 411 | if rv.Kind() == reflect.Ptr && !rv.IsNil() { 412 | rv = rv.Elem() 413 | if rv.IsValid() && rv.Kind() == reflect.Struct { 414 | if f := rv.FieldByName("DBMAntennaSignal"); f.IsValid() { 415 | switch f.Kind() { 416 | case reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int: 417 | return int(f.Int()) 418 | case reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: 419 | u := f.Uint() 420 | if u <= 0xFF { 421 | return int(int8(uint8(u))) 422 | } 423 | return int(u) 424 | } 425 | } 426 | } 427 | } 428 | 429 | // fallback: scan RadioTapValues for a 1-byte RSSI-like value (-150..0) 430 | for _, ns := range r.RadioTapValues { 431 | sv := reflect.ValueOf(ns) 432 | if sv.Kind() == reflect.Struct { 433 | for _, name := range []string{"Data", "Value"} { 434 | if f := sv.FieldByName(name); f.IsValid() && f.Kind() == reflect.Slice && f.Type().Elem().Kind() == reflect.Uint8 { 435 | b := f.Bytes() 436 | if len(b) >= 1 { 437 | rssi := int(int8(b[0])) 438 | if rssi <= 0 && rssi >= -150 { 439 | return rssi 440 | } 441 | } 442 | } 443 | } 444 | } 445 | } 446 | 447 | return 0 448 | } 449 | -------------------------------------------------------------------------------- /LICENSE.md: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | ========================== 3 | 4 | Version 3, 29 June 2007 5 | 6 | Copyright © 2007 Free Software Foundation, Inc. <> 7 | 8 | Everyone is permitted to copy and distribute verbatim copies of this license 9 | document, but changing it is not allowed. 10 | 11 | ## Preamble 12 | 13 | The GNU General Public License is a free, copyleft license for software and other 14 | kinds of works. 15 | 16 | The licenses for most software and other practical works are designed to take away 17 | your freedom to share and change the works. By contrast, the GNU General Public 18 | License is intended to guarantee your freedom to share and change all versions of a 19 | program--to make sure it remains free software for all its users. We, the Free 20 | Software Foundation, use the GNU General Public License for most of our software; it 21 | applies also to any other work released this way by its authors. You can apply it to 22 | your programs, too. 23 | 24 | When we speak of free software, we are referring to freedom, not price. Our General 25 | Public Licenses are designed to make sure that you have the freedom to distribute 26 | copies of free software (and charge for them if you wish), that you receive source 27 | code or can get it if you want it, that you can change the software or use pieces of 28 | it in new free programs, and that you know you can do these things. 29 | 30 | To protect your rights, we need to prevent others from denying you these rights or 31 | asking you to surrender the rights. Therefore, you have certain responsibilities if 32 | you distribute copies of the software, or if you modify it: responsibilities to 33 | respect the freedom of others. 34 | 35 | For example, if you distribute copies of such a program, whether gratis or for a fee, 36 | you must pass on to the recipients the same freedoms that you received. You must make 37 | sure that they, too, receive or can get the source code. And you must show them these 38 | terms so they know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: (1) assert 41 | copyright on the software, and (2) offer you this License giving you legal permission 42 | to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains that there is 45 | no warranty for this free software. For both users' and authors' sake, the GPL 46 | requires that modified versions be marked as changed, so that their problems will not 47 | be attributed erroneously to authors of previous versions. 48 | 49 | Some devices are designed to deny users access to install or run modified versions of 50 | the software inside them, although the manufacturer can do so. This is fundamentally 51 | incompatible with the aim of protecting users' freedom to change the software. The 52 | systematic pattern of such abuse occurs in the area of products for individuals to 53 | use, which is precisely where it is most unacceptable. Therefore, we have designed 54 | this version of the GPL to prohibit the practice for those products. If such problems 55 | arise substantially in other domains, we stand ready to extend this provision to 56 | those domains in future versions of the GPL, as needed to protect the freedom of 57 | users. 58 | 59 | Finally, every program is threatened constantly by software patents. States should 60 | not allow patents to restrict development and use of software on general-purpose 61 | computers, but in those that do, we wish to avoid the special danger that patents 62 | applied to a free program could make it effectively proprietary. To prevent this, the 63 | GPL assures that patents cannot be used to render the program non-free. 64 | 65 | The precise terms and conditions for copying, distribution and modification follow. 66 | 67 | ## TERMS AND CONDITIONS 68 | 69 | ### 0. Definitions. 70 | 71 | “This License” refers to version 3 of the GNU General Public License. 72 | 73 | “Copyright” also means copyright-like laws that apply to other kinds of 74 | works, such as semiconductor masks. 75 | 76 | “The Program” refers to any copyrightable work licensed under this 77 | License. Each licensee is addressed as “you”. “Licensees” and 78 | “recipients” may be individuals or organizations. 79 | 80 | To “modify” a work means to copy from or adapt all or part of the work in 81 | a fashion requiring copyright permission, other than the making of an exact copy. The 82 | resulting work is called a “modified version” of the earlier work or a 83 | work “based on” the earlier work. 84 | 85 | A “covered work” means either the unmodified Program or a work based on 86 | the Program. 87 | 88 | To “propagate” a work means to do anything with it that, without 89 | permission, would make you directly or secondarily liable for infringement under 90 | applicable copyright law, except executing it on a computer or modifying a private 91 | copy. Propagation includes copying, distribution (with or without modification), 92 | making available to the public, and in some countries other activities as well. 93 | 94 | To “convey” a work means any kind of propagation that enables other 95 | parties to make or receive copies. Mere interaction with a user through a computer 96 | network, with no transfer of a copy, is not conveying. 97 | 98 | An interactive user interface displays “Appropriate Legal Notices” to the 99 | extent that it includes a convenient and prominently visible feature that (1) 100 | displays an appropriate copyright notice, and (2) tells the user that there is no 101 | warranty for the work (except to the extent that warranties are provided), that 102 | licensees may convey the work under this License, and how to view a copy of this 103 | License. If the interface presents a list of user commands or options, such as a 104 | menu, a prominent item in the list meets this criterion. 105 | 106 | ### 1. Source Code. 107 | 108 | The “source code” for a work means the preferred form of the work for 109 | making modifications to it. “Object code” means any non-source form of a 110 | work. 111 | 112 | A “Standard Interface” means an interface that either is an official 113 | standard defined by a recognized standards body, or, in the case of interfaces 114 | specified for a particular programming language, one that is widely used among 115 | developers working in that language. 116 | 117 | The “System Libraries” of an executable work include anything, other than 118 | the work as a whole, that (a) is included in the normal form of packaging a Major 119 | Component, but which is not part of that Major Component, and (b) serves only to 120 | enable use of the work with that Major Component, or to implement a Standard 121 | Interface for which an implementation is available to the public in source code form. 122 | A “Major Component”, in this context, means a major essential component 123 | (kernel, window system, and so on) of the specific operating system (if any) on which 124 | the executable work runs, or a compiler used to produce the work, or an object code 125 | interpreter used to run it. 126 | 127 | The “Corresponding Source” for a work in object code form means all the 128 | source code needed to generate, install, and (for an executable work) run the object 129 | code and to modify the work, including scripts to control those activities. However, 130 | it does not include the work's System Libraries, or general-purpose tools or 131 | generally available free programs which are used unmodified in performing those 132 | activities but which are not part of the work. For example, Corresponding Source 133 | includes interface definition files associated with source files for the work, and 134 | the source code for shared libraries and dynamically linked subprograms that the work 135 | is specifically designed to require, such as by intimate data communication or 136 | control flow between those subprograms and other parts of the work. 137 | 138 | The Corresponding Source need not include anything that users can regenerate 139 | automatically from other parts of the Corresponding Source. 140 | 141 | The Corresponding Source for a work in source code form is that same work. 142 | 143 | ### 2. Basic Permissions. 144 | 145 | All rights granted under this License are granted for the term of copyright on the 146 | Program, and are irrevocable provided the stated conditions are met. This License 147 | explicitly affirms your unlimited permission to run the unmodified Program. The 148 | output from running a covered work is covered by this License only if the output, 149 | given its content, constitutes a covered work. This License acknowledges your rights 150 | of fair use or other equivalent, as provided by copyright law. 151 | 152 | You may make, run and propagate covered works that you do not convey, without 153 | conditions so long as your license otherwise remains in force. You may convey covered 154 | works to others for the sole purpose of having them make modifications exclusively 155 | for you, or provide you with facilities for running those works, provided that you 156 | comply with the terms of this License in conveying all material for which you do not 157 | control copyright. Those thus making or running the covered works for you must do so 158 | exclusively on your behalf, under your direction and control, on terms that prohibit 159 | them from making any copies of your copyrighted material outside their relationship 160 | with you. 161 | 162 | Conveying under any other circumstances is permitted solely under the conditions 163 | stated below. Sublicensing is not allowed; section 10 makes it unnecessary. 164 | 165 | ### 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 166 | 167 | No covered work shall be deemed part of an effective technological measure under any 168 | applicable law fulfilling obligations under article 11 of the WIPO copyright treaty 169 | adopted on 20 December 1996, or similar laws prohibiting or restricting circumvention 170 | of such measures. 171 | 172 | When you convey a covered work, you waive any legal power to forbid circumvention of 173 | technological measures to the extent such circumvention is effected by exercising 174 | rights under this License with respect to the covered work, and you disclaim any 175 | intention to limit operation or modification of the work as a means of enforcing, 176 | against the work's users, your or third parties' legal rights to forbid circumvention 177 | of technological measures. 178 | 179 | ### 4. Conveying Verbatim Copies. 180 | 181 | You may convey verbatim copies of the Program's source code as you receive it, in any 182 | medium, provided that you conspicuously and appropriately publish on each copy an 183 | appropriate copyright notice; keep intact all notices stating that this License and 184 | any non-permissive terms added in accord with section 7 apply to the code; keep 185 | intact all notices of the absence of any warranty; and give all recipients a copy of 186 | this License along with the Program. 187 | 188 | You may charge any price or no price for each copy that you convey, and you may offer 189 | support or warranty protection for a fee. 190 | 191 | ### 5. Conveying Modified Source Versions. 192 | 193 | You may convey a work based on the Program, or the modifications to produce it from 194 | the Program, in the form of source code under the terms of section 4, provided that 195 | you also meet all of these conditions: 196 | 197 | * **a)** The work must carry prominent notices stating that you modified it, and giving a 198 | relevant date. 199 | * **b)** The work must carry prominent notices stating that it is released under this 200 | License and any conditions added under section 7. This requirement modifies the 201 | requirement in section 4 to “keep intact all notices”. 202 | * **c)** You must license the entire work, as a whole, under this License to anyone who 203 | comes into possession of a copy. This License will therefore apply, along with any 204 | applicable section 7 additional terms, to the whole of the work, and all its parts, 205 | regardless of how they are packaged. This License gives no permission to license the 206 | work in any other way, but it does not invalidate such permission if you have 207 | separately received it. 208 | * **d)** If the work has interactive user interfaces, each must display Appropriate Legal 209 | Notices; however, if the Program has interactive interfaces that do not display 210 | Appropriate Legal Notices, your work need not make them do so. 211 | 212 | A compilation of a covered work with other separate and independent works, which are 213 | not by their nature extensions of the covered work, and which are not combined with 214 | it such as to form a larger program, in or on a volume of a storage or distribution 215 | medium, is called an “aggregate” if the compilation and its resulting 216 | copyright are not used to limit the access or legal rights of the compilation's users 217 | beyond what the individual works permit. Inclusion of a covered work in an aggregate 218 | does not cause this License to apply to the other parts of the aggregate. 219 | 220 | ### 6. Conveying Non-Source Forms. 221 | 222 | You may convey a covered work in object code form under the terms of sections 4 and 223 | 5, provided that you also convey the machine-readable Corresponding Source under the 224 | terms of this License, in one of these ways: 225 | 226 | * **a)** Convey the object code in, or embodied in, a physical product (including a 227 | physical distribution medium), accompanied by the Corresponding Source fixed on a 228 | durable physical medium customarily used for software interchange. 229 | * **b)** Convey the object code in, or embodied in, a physical product (including a 230 | physical distribution medium), accompanied by a written offer, valid for at least 231 | three years and valid for as long as you offer spare parts or customer support for 232 | that product model, to give anyone who possesses the object code either (1) a copy of 233 | the Corresponding Source for all the software in the product that is covered by this 234 | License, on a durable physical medium customarily used for software interchange, for 235 | a price no more than your reasonable cost of physically performing this conveying of 236 | source, or (2) access to copy the Corresponding Source from a network server at no 237 | charge. 238 | * **c)** Convey individual copies of the object code with a copy of the written offer to 239 | provide the Corresponding Source. This alternative is allowed only occasionally and 240 | noncommercially, and only if you received the object code with such an offer, in 241 | accord with subsection 6b. 242 | * **d)** Convey the object code by offering access from a designated place (gratis or for 243 | a charge), and offer equivalent access to the Corresponding Source in the same way 244 | through the same place at no further charge. You need not require recipients to copy 245 | the Corresponding Source along with the object code. If the place to copy the object 246 | code is a network server, the Corresponding Source may be on a different server 247 | (operated by you or a third party) that supports equivalent copying facilities, 248 | provided you maintain clear directions next to the object code saying where to find 249 | the Corresponding Source. Regardless of what server hosts the Corresponding Source, 250 | you remain obligated to ensure that it is available for as long as needed to satisfy 251 | these requirements. 252 | * **e)** Convey the object code using peer-to-peer transmission, provided you inform 253 | other peers where the object code and Corresponding Source of the work are being 254 | offered to the general public at no charge under subsection 6d. 255 | 256 | A separable portion of the object code, whose source code is excluded from the 257 | Corresponding Source as a System Library, need not be included in conveying the 258 | object code work. 259 | 260 | A “User Product” is either (1) a “consumer product”, which 261 | means any tangible personal property which is normally used for personal, family, or 262 | household purposes, or (2) anything designed or sold for incorporation into a 263 | dwelling. In determining whether a product is a consumer product, doubtful cases 264 | shall be resolved in favor of coverage. For a particular product received by a 265 | particular user, “normally used” refers to a typical or common use of 266 | that class of product, regardless of the status of the particular user or of the way 267 | in which the particular user actually uses, or expects or is expected to use, the 268 | product. A product is a consumer product regardless of whether the product has 269 | substantial commercial, industrial or non-consumer uses, unless such uses represent 270 | the only significant mode of use of the product. 271 | 272 | “Installation Information” for a User Product means any methods, 273 | procedures, authorization keys, or other information required to install and execute 274 | modified versions of a covered work in that User Product from a modified version of 275 | its Corresponding Source. The information must suffice to ensure that the continued 276 | functioning of the modified object code is in no case prevented or interfered with 277 | solely because modification has been made. 278 | 279 | If you convey an object code work under this section in, or with, or specifically for 280 | use in, a User Product, and the conveying occurs as part of a transaction in which 281 | the right of possession and use of the User Product is transferred to the recipient 282 | in perpetuity or for a fixed term (regardless of how the transaction is 283 | characterized), the Corresponding Source conveyed under this section must be 284 | accompanied by the Installation Information. But this requirement does not apply if 285 | neither you nor any third party retains the ability to install modified object code 286 | on the User Product (for example, the work has been installed in ROM). 287 | 288 | The requirement to provide Installation Information does not include a requirement to 289 | continue to provide support service, warranty, or updates for a work that has been 290 | modified or installed by the recipient, or for the User Product in which it has been 291 | modified or installed. Access to a network may be denied when the modification itself 292 | materially and adversely affects the operation of the network or violates the rules 293 | and protocols for communication across the network. 294 | 295 | Corresponding Source conveyed, and Installation Information provided, in accord with 296 | this section must be in a format that is publicly documented (and with an 297 | implementation available to the public in source code form), and must require no 298 | special password or key for unpacking, reading or copying. 299 | 300 | ### 7. Additional Terms. 301 | 302 | “Additional permissions” are terms that supplement the terms of this 303 | License by making exceptions from one or more of its conditions. Additional 304 | permissions that are applicable to the entire Program shall be treated as though they 305 | were included in this License, to the extent that they are valid under applicable 306 | law. If additional permissions apply only to part of the Program, that part may be 307 | used separately under those permissions, but the entire Program remains governed by 308 | this License without regard to the additional permissions. 309 | 310 | When you convey a copy of a covered work, you may at your option remove any 311 | additional permissions from that copy, or from any part of it. (Additional 312 | permissions may be written to require their own removal in certain cases when you 313 | modify the work.) You may place additional permissions on material, added by you to a 314 | covered work, for which you have or can give appropriate copyright permission. 315 | 316 | Notwithstanding any other provision of this License, for material you add to a 317 | covered work, you may (if authorized by the copyright holders of that material) 318 | supplement the terms of this License with terms: 319 | 320 | * **a)** Disclaiming warranty or limiting liability differently from the terms of 321 | sections 15 and 16 of this License; or 322 | * **b)** Requiring preservation of specified reasonable legal notices or author 323 | attributions in that material or in the Appropriate Legal Notices displayed by works 324 | containing it; or 325 | * **c)** Prohibiting misrepresentation of the origin of that material, or requiring that 326 | modified versions of such material be marked in reasonable ways as different from the 327 | original version; or 328 | * **d)** Limiting the use for publicity purposes of names of licensors or authors of the 329 | material; or 330 | * **e)** Declining to grant rights under trademark law for use of some trade names, 331 | trademarks, or service marks; or 332 | * **f)** Requiring indemnification of licensors and authors of that material by anyone 333 | who conveys the material (or modified versions of it) with contractual assumptions of 334 | liability to the recipient, for any liability that these contractual assumptions 335 | directly impose on those licensors and authors. 336 | 337 | All other non-permissive additional terms are considered “further 338 | restrictions” within the meaning of section 10. If the Program as you received 339 | it, or any part of it, contains a notice stating that it is governed by this License 340 | along with a term that is a further restriction, you may remove that term. If a 341 | license document contains a further restriction but permits relicensing or conveying 342 | under this License, you may add to a covered work material governed by the terms of 343 | that license document, provided that the further restriction does not survive such 344 | relicensing or conveying. 345 | 346 | If you add terms to a covered work in accord with this section, you must place, in 347 | the relevant source files, a statement of the additional terms that apply to those 348 | files, or a notice indicating where to find the applicable terms. 349 | 350 | Additional terms, permissive or non-permissive, may be stated in the form of a 351 | separately written license, or stated as exceptions; the above requirements apply 352 | either way. 353 | 354 | ### 8. Termination. 355 | 356 | You may not propagate or modify a covered work except as expressly provided under 357 | this License. Any attempt otherwise to propagate or modify it is void, and will 358 | automatically terminate your rights under this License (including any patent licenses 359 | granted under the third paragraph of section 11). 360 | 361 | However, if you cease all violation of this License, then your license from a 362 | particular copyright holder is reinstated (a) provisionally, unless and until the 363 | copyright holder explicitly and finally terminates your license, and (b) permanently, 364 | if the copyright holder fails to notify you of the violation by some reasonable means 365 | prior to 60 days after the cessation. 366 | 367 | Moreover, your license from a particular copyright holder is reinstated permanently 368 | if the copyright holder notifies you of the violation by some reasonable means, this 369 | is the first time you have received notice of violation of this License (for any 370 | work) from that copyright holder, and you cure the violation prior to 30 days after 371 | your receipt of the notice. 372 | 373 | Termination of your rights under this section does not terminate the licenses of 374 | parties who have received copies or rights from you under this License. If your 375 | rights have been terminated and not permanently reinstated, you do not qualify to 376 | receive new licenses for the same material under section 10. 377 | 378 | ### 9. Acceptance Not Required for Having Copies. 379 | 380 | You are not required to accept this License in order to receive or run a copy of the 381 | Program. Ancillary propagation of a covered work occurring solely as a consequence of 382 | using peer-to-peer transmission to receive a copy likewise does not require 383 | acceptance. However, nothing other than this License grants you permission to 384 | propagate or modify any covered work. These actions infringe copyright if you do not 385 | accept this License. Therefore, by modifying or propagating a covered work, you 386 | indicate your acceptance of this License to do so. 387 | 388 | ### 10. Automatic Licensing of Downstream Recipients. 389 | 390 | Each time you convey a covered work, the recipient automatically receives a license 391 | from the original licensors, to run, modify and propagate that work, subject to this 392 | License. You are not responsible for enforcing compliance by third parties with this 393 | License. 394 | 395 | An “entity transaction” is a transaction transferring control of an 396 | organization, or substantially all assets of one, or subdividing an organization, or 397 | merging organizations. If propagation of a covered work results from an entity 398 | transaction, each party to that transaction who receives a copy of the work also 399 | receives whatever licenses to the work the party's predecessor in interest had or 400 | could give under the previous paragraph, plus a right to possession of the 401 | Corresponding Source of the work from the predecessor in interest, if the predecessor 402 | has it or can get it with reasonable efforts. 403 | 404 | You may not impose any further restrictions on the exercise of the rights granted or 405 | affirmed under this License. For example, you may not impose a license fee, royalty, 406 | or other charge for exercise of rights granted under this License, and you may not 407 | initiate litigation (including a cross-claim or counterclaim in a lawsuit) alleging 408 | that any patent claim is infringed by making, using, selling, offering for sale, or 409 | importing the Program or any portion of it. 410 | 411 | ### 11. Patents. 412 | 413 | A “contributor” is a copyright holder who authorizes use under this 414 | License of the Program or a work on which the Program is based. The work thus 415 | licensed is called the contributor's “contributor version”. 416 | 417 | A contributor's “essential patent claims” are all patent claims owned or 418 | controlled by the contributor, whether already acquired or hereafter acquired, that 419 | would be infringed by some manner, permitted by this License, of making, using, or 420 | selling its contributor version, but do not include claims that would be infringed 421 | only as a consequence of further modification of the contributor version. For 422 | purposes of this definition, “control” includes the right to grant patent 423 | sublicenses in a manner consistent with the requirements of this License. 424 | 425 | Each contributor grants you a non-exclusive, worldwide, royalty-free patent license 426 | under the contributor's essential patent claims, to make, use, sell, offer for sale, 427 | import and otherwise run, modify and propagate the contents of its contributor 428 | version. 429 | 430 | In the following three paragraphs, a “patent license” is any express 431 | agreement or commitment, however denominated, not to enforce a patent (such as an 432 | express permission to practice a patent or covenant not to sue for patent 433 | infringement). To “grant” such a patent license to a party means to make 434 | such an agreement or commitment not to enforce a patent against the party. 435 | 436 | If you convey a covered work, knowingly relying on a patent license, and the 437 | Corresponding Source of the work is not available for anyone to copy, free of charge 438 | and under the terms of this License, through a publicly available network server or 439 | other readily accessible means, then you must either (1) cause the Corresponding 440 | Source to be so available, or (2) arrange to deprive yourself of the benefit of the 441 | patent license for this particular work, or (3) arrange, in a manner consistent with 442 | the requirements of this License, to extend the patent license to downstream 443 | recipients. “Knowingly relying” means you have actual knowledge that, but 444 | for the patent license, your conveying the covered work in a country, or your 445 | recipient's use of the covered work in a country, would infringe one or more 446 | identifiable patents in that country that you have reason to believe are valid. 447 | 448 | If, pursuant to or in connection with a single transaction or arrangement, you 449 | convey, or propagate by procuring conveyance of, a covered work, and grant a patent 450 | license to some of the parties receiving the covered work authorizing them to use, 451 | propagate, modify or convey a specific copy of the covered work, then the patent 452 | license you grant is automatically extended to all recipients of the covered work and 453 | works based on it. 454 | 455 | A patent license is “discriminatory” if it does not include within the 456 | scope of its coverage, prohibits the exercise of, or is conditioned on the 457 | non-exercise of one or more of the rights that are specifically granted under this 458 | License. You may not convey a covered work if you are a party to an arrangement with 459 | a third party that is in the business of distributing software, under which you make 460 | payment to the third party based on the extent of your activity of conveying the 461 | work, and under which the third party grants, to any of the parties who would receive 462 | the covered work from you, a discriminatory patent license (a) in connection with 463 | copies of the covered work conveyed by you (or copies made from those copies), or (b) 464 | primarily for and in connection with specific products or compilations that contain 465 | the covered work, unless you entered into that arrangement, or that patent license 466 | was granted, prior to 28 March 2007. 467 | 468 | Nothing in this License shall be construed as excluding or limiting any implied 469 | license or other defenses to infringement that may otherwise be available to you 470 | under applicable patent law. 471 | 472 | ### 12. No Surrender of Others' Freedom. 473 | 474 | If conditions are imposed on you (whether by court order, agreement or otherwise) 475 | that contradict the conditions of this License, they do not excuse you from the 476 | conditions of this License. If you cannot convey a covered work so as to satisfy 477 | simultaneously your obligations under this License and any other pertinent 478 | obligations, then as a consequence you may not convey it at all. For example, if you 479 | agree to terms that obligate you to collect a royalty for further conveying from 480 | those to whom you convey the Program, the only way you could satisfy both those terms 481 | and this License would be to refrain entirely from conveying the Program. 482 | 483 | ### 13. Use with the GNU Affero General Public License. 484 | 485 | Notwithstanding any other provision of this License, you have permission to link or 486 | combine any covered work with a work licensed under version 3 of the GNU Affero 487 | General Public License into a single combined work, and to convey the resulting work. 488 | The terms of this License will continue to apply to the part which is the covered 489 | work, but the special requirements of the GNU Affero General Public License, section 490 | 13, concerning interaction through a network will apply to the combination as such. 491 | 492 | ### 14. Revised Versions of this License. 493 | 494 | The Free Software Foundation may publish revised and/or new versions of the GNU 495 | General Public License from time to time. Such new versions will be similar in spirit 496 | to the present version, but may differ in detail to address new problems or concerns. 497 | 498 | Each version is given a distinguishing version number. If the Program specifies that 499 | a certain numbered version of the GNU General Public License “or any later 500 | version” applies to it, you have the option of following the terms and 501 | conditions either of that numbered version or of any later version published by the 502 | Free Software Foundation. If the Program does not specify a version number of the GNU 503 | General Public License, you may choose any version ever published by the Free 504 | Software Foundation. 505 | 506 | If the Program specifies that a proxy can decide which future versions of the GNU 507 | General Public License can be used, that proxy's public statement of acceptance of a 508 | version permanently authorizes you to choose that version for the Program. 509 | 510 | Later license versions may give you additional or different permissions. However, no 511 | additional obligations are imposed on any author or copyright holder as a result of 512 | your choosing to follow a later version. 513 | 514 | ### 15. Disclaimer of Warranty. 515 | 516 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY APPLICABLE LAW. 517 | EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT HOLDERS AND/OR OTHER PARTIES 518 | PROVIDE THE PROGRAM “AS IS” WITHOUT WARRANTY OF ANY KIND, EITHER 519 | EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED WARRANTIES OF 520 | MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE. THE ENTIRE RISK AS TO THE 521 | QUALITY AND PERFORMANCE OF THE PROGRAM IS WITH YOU. SHOULD THE PROGRAM PROVE 522 | DEFECTIVE, YOU ASSUME THE COST OF ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 523 | 524 | ### 16. Limitation of Liability. 525 | 526 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING WILL ANY 527 | COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS THE PROGRAM AS 528 | PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY GENERAL, SPECIAL, 529 | INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE USE OR INABILITY TO USE THE 530 | PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF DATA OR DATA BEING RENDERED INACCURATE 531 | OR LOSSES SUSTAINED BY YOU OR THIRD PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE 532 | WITH ANY OTHER PROGRAMS), EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE 533 | POSSIBILITY OF SUCH DAMAGES. 534 | 535 | ### 17. Interpretation of Sections 15 and 16. 536 | 537 | If the disclaimer of warranty and limitation of liability provided above cannot be 538 | given local legal effect according to their terms, reviewing courts shall apply local 539 | law that most closely approximates an absolute waiver of all civil liability in 540 | connection with the Program, unless a warranty or assumption of liability accompanies 541 | a copy of the Program in return for a fee. 542 | 543 | END OF TERMS AND CONDITIONS 544 | 545 | ## How to Apply These Terms to Your New Programs 546 | 547 | If you develop a new program, and you want it to be of the greatest possible use to 548 | the public, the best way to achieve this is to make it free software which everyone 549 | can redistribute and change under these terms. 550 | 551 | To do so, attach the following notices to the program. It is safest to attach them 552 | to the start of each source file to most effectively state the exclusion of warranty; 553 | and each file should have at least the “copyright” line and a pointer to 554 | where the full notice is found. 555 | 556 | 557 | Copyright (C) 558 | 559 | This program is free software: you can redistribute it and/or modify 560 | it under the terms of the GNU General Public License as published by 561 | the Free Software Foundation, either version 3 of the License, or 562 | (at your option) any later version. 563 | 564 | This program is distributed in the hope that it will be useful, 565 | but WITHOUT ANY WARRANTY; without even the implied warranty of 566 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 567 | GNU General Public License for more details. 568 | 569 | You should have received a copy of the GNU General Public License 570 | along with this program. If not, see . 571 | 572 | Also add information on how to contact you by electronic and paper mail. 573 | 574 | If the program does terminal interaction, make it output a short notice like this 575 | when it starts in an interactive mode: 576 | 577 | Copyright (C) 578 | This program comes with ABSOLUTELY NO WARRANTY; for details type 'show w'. 579 | This is free software, and you are welcome to redistribute it 580 | under certain conditions; type 'show c' for details. 581 | 582 | The hypothetical commands 'show w' and 'show c' should show the appropriate parts of 583 | the General Public License. Of course, your program's commands might be different; 584 | for a GUI interface, you would use an “about box”. 585 | 586 | You should also get your employer (if you work as a programmer) or school, if any, to 587 | sign a “copyright disclaimer” for the program, if necessary. For more 588 | information on this, and how to apply and follow the GNU GPL, see 589 | <>. 590 | 591 | The GNU General Public License does not permit incorporating your program into 592 | proprietary programs. If your program is a subroutine library, you may consider it 593 | more useful to permit linking proprietary applications with the library. If this is 594 | what you want to do, use the GNU Lesser General Public License instead of this 595 | License. But first, please read 596 | <>. 597 | --------------------------------------------------------------------------------