diff --git a/aironet.go b/aironet.go new file mode 100644 index 0000000..71d8901 --- /dev/null +++ b/aironet.go @@ -0,0 +1,149 @@ +// Copyright 2014 Team 254. All Rights Reserved. +// Author: pat@patfairbank.com (Patrick Fairbank) +// +// Methods for configuring a Cisco Aironet AP1252AG access point for team SSIDs and VLANs. + +package main + +import ( + "bufio" + "bytes" + "fmt" + "net" + "regexp" + "strconv" + "sync" +) + +const aironetTelnetPort = 23 +const ( + red1Vlan = 11 + red2Vlan = 12 + red3Vlan = 13 + blue1Vlan = 14 + blue2Vlan = 15 + blue3Vlan = 16 +) + +var aironetMutex sync.Mutex + +// Sets up wireless networks for the given set of teams. +func ConfigureTeamWifi(red1, red2, red3, blue1, blue2, blue3 *Team) error { + for _, team := range []*Team{red1, red2, red3, blue1, blue2, blue3} { + if team != nil && (len(team.WpaKey) < 8 || len(team.WpaKey) > 63) { + return fmt.Errorf("Invalid WPA key '%s' configured for team %d.", team.WpaKey, team.Id) + } + } + + // Determine what new SSIDs are needed and build the commands to set them up. + oldSsids, err := getSsids() + if err != nil { + return err + } + addSsidsCommand := "" + associateSsidsCommand := "" + replaceSsid := func(team *Team, vlan int) { + if team == nil { + return + } + if oldSsids[strconv.Itoa(team.Id)] == vlan { + delete(oldSsids, strconv.Itoa(team.Id)) + } else { + addSsidsCommand += fmt.Sprintf("dot11 ssid %d\nvlan %d\nauthentication open\nauthentication "+ + "key-management wpa version 2\nmbssid guest-mode\nwpa-psk ascii %s\n", team.Id, vlan, team.WpaKey) + associateSsidsCommand += fmt.Sprintf("ssid %d\n", team.Id) + } + } + replaceSsid(red1, red1Vlan) + replaceSsid(red2, red2Vlan) + replaceSsid(red3, red3Vlan) + replaceSsid(blue1, blue1Vlan) + replaceSsid(blue2, blue2Vlan) + replaceSsid(blue3, blue3Vlan) + if len(addSsidsCommand) != 0 { + associateSsidsCommand = "interface Dot11Radio1\n" + associateSsidsCommand + } + + // Build the command to remove the SSIDs that are no longer needed. + removeSsidsCommand := "" + for ssid, _ := range oldSsids { + removeSsidsCommand += fmt.Sprintf("no dot11 ssid %s\n", ssid) + } + + command := removeSsidsCommand + addSsidsCommand + associateSsidsCommand + if len(command) > 0 { + _, err = runAironetConfigCommand(removeSsidsCommand + addSsidsCommand + associateSsidsCommand) + if err != nil { + return err + } + } + + return nil +} + +// Returns a map of currently-configured SSIDs to VLANs. +func getSsids() (map[string]int, error) { + // Get the entire config dump. + config, err := runAironetCommand("show running-config\n") + if err != nil { + return nil, err + } + + // Parse out the SSIDs and VLANs from the config dump. + re := regexp.MustCompile("(?s)dot11 ssid (\\w+)\\s+vlan (\\d+)") + ssidMatches := re.FindAllStringSubmatch(config, -1) + if ssidMatches == nil { + // There are probably no SSIDs currently configured. + return nil, nil + } + + // Build the map of SSID to VLAN. + ssids := make(map[string]int) + for _, match := range ssidMatches { + vlan, _ := strconv.Atoi(match[2]) + ssids[match[1]] = vlan + } + return ssids, nil +} + +// Logs into the Aironet via Telnet and runs the given command in user exec mode. Reads the output and returns +// it as a string. +func runAironetCommand(command string) (string, error) { + // Make sure multiple commands aren't being run at the same time. + aironetMutex.Lock() + defer aironetMutex.Unlock() + + // Open a Telnet connection to the AP. + conn, err := net.Dial("tcp", fmt.Sprintf("%s:%d", eventSettings.ApAddress, aironetTelnetPort)) + if err != nil { + return "", err + } + defer conn.Close() + + // Login to the AP, send the command, and log out all at once. + writer := bufio.NewWriter(conn) + _, err = writer.WriteString(fmt.Sprintf("%s\n%s\nterminal length 0\n%sexit\n", eventSettings.ApUsername, + eventSettings.ApPassword, command)) + if err != nil { + return "", err + } + err = writer.Flush() + if err != nil { + return "", err + } + + // Read the response. + var reader bytes.Buffer + _, err = reader.ReadFrom(conn) + if err != nil { + return "", err + } + return reader.String(), nil +} + +// Logs into the Aironet via Telnet and runs the given command in global configuration mode. Reads the output +// and returns it as a string. +func runAironetConfigCommand(command string) (string, error) { + return runAironetCommand(fmt.Sprintf("config terminal\n%send\ncopy running-config startup-config\n\n", + command)) +} diff --git a/ap_config.txt b/ap_config.txt new file mode 100644 index 0000000..aa2f165 --- /dev/null +++ b/ap_config.txt @@ -0,0 +1,306 @@ + +! +! Last configuration change at 05:36:53 UTC Sat Aug 16 2014 by patfair +! NVRAM config last updated at 05:36:53 UTC Sat Aug 16 2014 by patfair +! NVRAM config last updated at 05:36:53 UTC Sat Aug 16 2014 by patfair +version 15.2 +no service pad +service timestamps debug datetime msec +service timestamps log datetime msec +service password-encryption +! +hostname ChezyAP +! +logging rate-limit console 9 +! +aaa new-model +! +! +aaa authentication login default local +aaa authorization exec default local +! +! +! +! +! +aaa session-id common +! +! +dot11 syslog +dot11 vlan-name Blue1 vlan 14 +dot11 vlan-name Blue2 vlan 15 +dot11 vlan-name Blue3 vlan 16 +dot11 vlan-name CheesyArena vlan 2 +dot11 vlan-name Red1 vlan 11 +dot11 vlan-name Red2 vlan 12 +dot11 vlan-name Red3 vlan 13 +! +dot11 ssid 1 + vlan 11 + authentication open + authentication key-management wpa version 2 + mbssid guest-mode + wpa-psk ascii 7 0257550A5A575E701D +! +dot11 ssid 2 + vlan 12 + authentication open + authentication key-management wpa version 2 + mbssid guest-mode + wpa-psk ascii 7 06545D731E1C5B4B57 +! +dot11 ssid 3 + vlan 13 + authentication open + authentication key-management wpa version 2 + mbssid guest-mode + wpa-psk ascii 7 115A4A564441585F57 +! +dot11 ssid 4 + vlan 14 + authentication open + authentication key-management wpa version 2 + mbssid guest-mode + wpa-psk ascii 7 101A5D4D5143465F58 +! +dot11 ssid 5 + vlan 15 + authentication open + authentication key-management wpa version 2 + mbssid guest-mode + wpa-psk ascii 7 00514653510E5E535A +! +dot11 ssid 6 + vlan 16 + authentication open + authentication key-management wpa version 2 + mbssid guest-mode + wpa-psk ascii 7 1441445D5A527C7D72 +! +dot11 ssid Cheesy Arena + vlan 2 + authentication open + authentication key-management wpa version 2 + guest-mode + wpa-psk ascii 7 030D5704100A22435C071D0A1001 +! +crypto pki token default removal timeout 0 +! +! +username patfair privilege 15 password 7 0548545F70181C2F101343 +! +! +bridge irb +! +! +interface Dot11Radio0 + no ip address + no ip route-cache + ! + encryption mode ciphers aes-ccm tkip + ! + encryption vlan 2 mode ciphers aes-ccm tkip + ! + ssid Cheesy Arena + ! + antenna gain 0 + station-role root + no dot11 extension aironet + bridge-group 1 + bridge-group 1 subscriber-loop-control + bridge-group 1 spanning-disabled + bridge-group 1 block-unknown-source + no bridge-group 1 source-learning + no bridge-group 1 unicast-flooding +! +interface Dot11Radio0.2 + encapsulation dot1Q 2 + no ip route-cache + bridge-group 2 + bridge-group 2 subscriber-loop-control + bridge-group 2 spanning-disabled + bridge-group 2 block-unknown-source + no bridge-group 2 source-learning + no bridge-group 2 unicast-flooding +! +interface Dot11Radio1 + no ip address + no ip route-cache + ! + encryption mode ciphers aes-ccm tkip + ! + encryption vlan 11 mode ciphers aes-ccm tkip + ! + encryption vlan 12 mode ciphers aes-ccm tkip + ! + encryption vlan 13 mode ciphers aes-ccm tkip + ! + encryption vlan 14 mode ciphers aes-ccm tkip + ! + encryption vlan 15 mode ciphers aes-ccm tkip + ! + encryption vlan 16 mode ciphers aes-ccm tkip + ! + ssid 1 + ! + ssid 2 + ! + ssid 3 + ! + ssid 4 + ! + ssid 5 + ! + ssid 6 + ! + antenna gain 0 + dfs band 3 block + mbssid + channel dfs + station-role root + no dot11 extension aironet + bridge-group 1 + bridge-group 1 subscriber-loop-control + bridge-group 1 spanning-disabled + bridge-group 1 block-unknown-source + no bridge-group 1 source-learning + no bridge-group 1 unicast-flooding +! +interface Dot11Radio1.11 + encapsulation dot1Q 11 + no ip route-cache + bridge-group 11 + bridge-group 11 subscriber-loop-control + bridge-group 11 spanning-disabled + bridge-group 11 block-unknown-source + no bridge-group 11 source-learning + no bridge-group 11 unicast-flooding +! +interface Dot11Radio1.12 + encapsulation dot1Q 12 + no ip route-cache + bridge-group 12 + bridge-group 12 subscriber-loop-control + bridge-group 12 spanning-disabled + bridge-group 12 block-unknown-source + no bridge-group 12 source-learning + no bridge-group 12 unicast-flooding +! +interface Dot11Radio1.13 + encapsulation dot1Q 13 + no ip route-cache + bridge-group 13 + bridge-group 13 subscriber-loop-control + bridge-group 13 spanning-disabled + bridge-group 13 block-unknown-source + no bridge-group 13 source-learning + no bridge-group 13 unicast-flooding +! +interface Dot11Radio1.14 + encapsulation dot1Q 14 + no ip route-cache + bridge-group 14 + bridge-group 14 subscriber-loop-control + bridge-group 14 spanning-disabled + bridge-group 14 block-unknown-source + no bridge-group 14 source-learning + no bridge-group 14 unicast-flooding +! +interface Dot11Radio1.15 + encapsulation dot1Q 15 + no ip route-cache + bridge-group 15 + bridge-group 15 subscriber-loop-control + bridge-group 15 spanning-disabled + bridge-group 15 block-unknown-source + no bridge-group 15 source-learning + no bridge-group 15 unicast-flooding +! +interface Dot11Radio1.16 + encapsulation dot1Q 16 + no ip route-cache + bridge-group 16 + bridge-group 16 subscriber-loop-control + bridge-group 16 spanning-disabled + bridge-group 16 block-unknown-source + no bridge-group 16 source-learning + no bridge-group 16 unicast-flooding +! +interface GigabitEthernet0 + no ip address + no ip route-cache + duplex auto + speed auto + bridge-group 1 + bridge-group 1 spanning-disabled + no bridge-group 1 source-learning +! +interface GigabitEthernet0.2 + encapsulation dot1Q 2 + no ip route-cache + bridge-group 2 + bridge-group 2 spanning-disabled + no bridge-group 2 source-learning +! +interface GigabitEthernet0.11 + encapsulation dot1Q 11 + no ip route-cache + bridge-group 11 + bridge-group 11 spanning-disabled + no bridge-group 11 source-learning +! +interface GigabitEthernet0.12 + encapsulation dot1Q 12 + no ip route-cache + bridge-group 12 + bridge-group 12 spanning-disabled + no bridge-group 12 source-learning +! +interface GigabitEthernet0.13 + encapsulation dot1Q 13 + no ip route-cache + bridge-group 13 + bridge-group 13 spanning-disabled + no bridge-group 13 source-learning +! +interface GigabitEthernet0.14 + encapsulation dot1Q 14 + no ip route-cache + bridge-group 14 + bridge-group 14 spanning-disabled + no bridge-group 14 source-learning +! +interface GigabitEthernet0.15 + encapsulation dot1Q 15 + no ip route-cache + bridge-group 15 + bridge-group 15 spanning-disabled + no bridge-group 15 source-learning +! +interface GigabitEthernet0.16 + encapsulation dot1Q 16 + no ip route-cache + bridge-group 16 + bridge-group 16 spanning-disabled + no bridge-group 16 source-learning +! +interface BVI1 + ip address 10.0.0.60 255.0.0.0 + no ip route-cache +! +ip default-gateway 10.0.0.1 +ip http server +ip http authentication aaa +no ip http secure-server +ip http help-path http://www.cisco.com/warp/public/779/smbiz/prodconfig/help/eag +! +bridge 1 route ip +! +! +! +line con 0 +line vty 0 4 + transport input all +! +sntp server 216.66.0.142 +end \ No newline at end of file diff --git a/arena.go b/arena.go index 7394fac..f9b59d4 100644 --- a/arena.go +++ b/arena.go @@ -197,6 +197,8 @@ func (arena *Arena) LoadMatch(match *Match) error { return err } + arena.SetupNetwork() + // Reset the realtime scores. arena.redRealtimeScore = new(RealtimeScore) arena.blueRealtimeScore = new(RealtimeScore) @@ -256,10 +258,25 @@ func (arena *Arena) SubstituteTeam(teamId int, station string) error { case "B3": arena.currentMatch.Blue3 = teamId } + arena.SetupNetwork() arena.matchLoadTeamsNotifier.Notify(nil) return nil } +// Asynchronously reconfigures the networking hardware for the new set of teams. +func (arena *Arena) SetupNetwork() { + if eventSettings.NetworkSecurityEnabled { + go func() { + err := ConfigureTeamWifi(arena.AllianceStations["R1"].team, arena.AllianceStations["R2"].team, + arena.AllianceStations["R3"].team, arena.AllianceStations["B1"].team, + arena.AllianceStations["B2"].team, arena.AllianceStations["B3"].team) + if err != nil { + log.Printf("Failed to configure team WiFi: %s", err.Error()) + } + }() + } +} + // Returns nil if the match can be started, and an error otherwise. func (arena *Arena) CheckCanStartMatch() error { if arena.MatchState != PRE_MATCH { diff --git a/db/migrations/20140520222523_CreateTeams.sql b/db/migrations/20140520222523_CreateTeams.sql index 43a375f..141b823 100644 --- a/db/migrations/20140520222523_CreateTeams.sql +++ b/db/migrations/20140520222523_CreateTeams.sql @@ -8,7 +8,8 @@ CREATE TABLE teams ( country VARCHAR(255), rookieyear int, robotname VARCHAR(255), - accomplishments VARCHAR(1000) + accomplishments VARCHAR(1000), + wpakey VARCHAR(16) ); -- +goose Down diff --git a/db/migrations/20140524160241_CreateEventSettings.sql b/db/migrations/20140524160241_CreateEventSettings.sql index 6f2dc9f..0ca2ba2 100644 --- a/db/migrations/20140524160241_CreateEventSettings.sql +++ b/db/migrations/20140524160241_CreateEventSettings.sql @@ -10,7 +10,11 @@ CREATE TABLE event_settings ( tbapublishingenabled bool, tbaeventcode VARCHAR(16), tbasecretid VARCHAR(255), - tbasecret VARCHAR(255) + tbasecret VARCHAR(255), + networksecurityenabled bool, + apaddress VARCHAR(255), + apusername VARCHAR(255), + appassword VARCHAR(255) ); -- +goose Down diff --git a/event_settings.go b/event_settings.go index adea064..96ff56d 100644 --- a/event_settings.go +++ b/event_settings.go @@ -17,6 +17,10 @@ type EventSettings struct { TbaEventCode string TbaSecretId string TbaSecret string + NetworkSecurityEnabled bool + ApAddress string + ApUsername string + ApPassword string } const eventSettingsId = 0 diff --git a/setup_settings.go b/setup_settings.go index 20de66c..4ce6be0 100644 --- a/setup_settings.go +++ b/setup_settings.go @@ -45,6 +45,10 @@ func SettingsPostHandler(w http.ResponseWriter, r *http.Request) { eventSettings.TbaEventCode = r.PostFormValue("tbaEventCode") eventSettings.TbaSecretId = r.PostFormValue("tbaSecretId") eventSettings.TbaSecret = r.PostFormValue("tbaSecret") + eventSettings.NetworkSecurityEnabled = r.PostFormValue("networkSecurityEnabled") == "on" + eventSettings.ApAddress = r.PostFormValue("apAddress") + eventSettings.ApUsername = r.PostFormValue("apUsername") + eventSettings.ApPassword = r.PostFormValue("apPassword") err := db.SaveEventSettings(eventSettings) if err != nil { handleWebErr(w, err) diff --git a/setup_teams.go b/setup_teams.go index 0f2aa59..171ad5a 100644 --- a/setup_teams.go +++ b/setup_teams.go @@ -8,6 +8,7 @@ package main import ( "encoding/csv" "fmt" + "github.com/dchest/uniuri" "github.com/gorilla/mux" "html" "html/template" @@ -19,6 +20,8 @@ import ( "strings" ) +const wpaKeyLength = 8 + var officialTeamInfoUrl = "https://my.usfirst.org/frc/scoring/index.lasso?page=teamlist" var officialTeamInfo map[int][]string @@ -124,6 +127,13 @@ func TeamEditPostHandler(w http.ResponseWriter, r *http.Request) { team.RookieYear, _ = strconv.Atoi(r.PostFormValue("rookieYear")) team.RobotName = r.PostFormValue("robotName") team.Accomplishments = r.PostFormValue("accomplishments") + if eventSettings.NetworkSecurityEnabled { + team.WpaKey = r.PostFormValue("wpaKey") + if len(team.WpaKey) < 8 || len(team.WpaKey) > 63 { + handleWebErr(w, fmt.Errorf("WPA key must be between 8 and 63 characters.")) + return + } + } err = db.SaveTeam(team) if err != nil { handleWebErr(w, err) @@ -168,6 +178,28 @@ func TeamsPublishHandler(w http.ResponseWriter, r *http.Request) { http.Redirect(w, r, "/setup/teams", 302) } +// Generates random WPA keys and saves them to the team models. +func TeamsGenerateWpaKeysHandler(w http.ResponseWriter, r *http.Request) { + generateAllKeys := false + if all, ok := r.URL.Query()["all"]; ok { + generateAllKeys = all[0] == "true" + } + + teams, err := db.GetAllTeams() + if err != nil { + handleWebErr(w, err) + return + } + for _, team := range teams { + if len(team.WpaKey) == 0 || generateAllKeys { + team.WpaKey = uniuri.NewLen(wpaKeyLength) + db.SaveTeam(&team) + } + } + + http.Redirect(w, r, "/setup/teams", 302) +} + func renderTeams(w http.ResponseWriter, r *http.Request, showErrorMessage bool) { teams, err := db.GetAllTeams() if err != nil { diff --git a/team.go b/team.go index 35ffdd1..ec2abdd 100644 --- a/team.go +++ b/team.go @@ -15,6 +15,7 @@ type Team struct { RookieYear int RobotName string Accomplishments string + WpaKey string } func (database *Database) CreateTeam(team *Team) error { diff --git a/templates/edit_team.html b/templates/edit_team.html index 01442f8..c9afee1 100644 --- a/templates/edit_team.html +++ b/templates/edit_team.html @@ -54,6 +54,14 @@ + {{if .EventSettings.NetworkSecurityEnabled}} +