diff --git a/cmd/boxinfo.go b/cmd/boxinfo.go new file mode 100644 index 00000000..757cdc5a --- /dev/null +++ b/cmd/boxinfo.go @@ -0,0 +1,36 @@ +package cmd + +import ( + "fmt" + + "github.com/bpicode/fritzctl/console" + "github.com/bpicode/fritzctl/fritz" + "github.com/spf13/cobra" +) + +var boxInfoCmd = &cobra.Command{ + Use: "boxinfo", + Short: "Display information about the FRITZ!Box", + Long: "Show information about the FRITZ!Box like firmware version, uptime, etc.", + Example: "fritzctl boxinfo", + RunE: boxInfo, +} + +func init() { + RootCmd.AddCommand(boxInfoCmd) +} + +func boxInfo(_ *cobra.Command, _ []string) error { + c := clientLogin() + f := fritz.NewInternal(c) + info, err := f.BoxInfo() + assertNoErr(err, "cannot obtain FRITZ!Box data") + printBoxInfo(info) + return nil +} + +func printBoxInfo(boxData *fritz.BoxData) { + fmt.Printf("%s %s\n", console.Cyan("Model: "), boxData.Model) + fmt.Printf("%s %s\n", console.Cyan("Firmware: "), boxData.FirmwareVersion) + fmt.Printf("%s %s\n", console.Cyan("Running: "), boxData.Runtime) +} diff --git a/cmd/commands_test.go b/cmd/commands_test.go index c0569398..7403a194 100644 --- a/cmd/commands_test.go +++ b/cmd/commands_test.go @@ -38,7 +38,9 @@ func TestCommands(t *testing.T) { {cmd: listCallsCmd, args: []string{}, srv: mock.New().UnstartedServer()}, {cmd: listSwitchesCmd, srv: mock.New().UnstartedServer()}, {cmd: listThermostatsCmd, srv: mock.New().UnstartedServer()}, + {cmd: listThermostatsCmd, srv: mock.New().UnstartedServer()}, {cmd: docManCmd, srv: mock.New().UnstartedServer()}, + {cmd: boxInfoCmd, srv: mock.New().UnstartedServer()}, } for _, testCase := range testCases { t.Run(fmt.Sprintf("Test run command %s", testCase.cmd.Name()), func(t *testing.T) { diff --git a/console/colors.go b/console/colors.go new file mode 100644 index 00000000..02e31151 --- /dev/null +++ b/console/colors.go @@ -0,0 +1,14 @@ +package console + +import "github.com/fatih/color" + +var ( + // Red can be used as Sprintf, where the output it wrapped in escape characters which will render the text red in terminals. + Red = color.New(color.Bold, color.FgRed).SprintfFunc() + // Green can be used as Sprintf, where the output it wrapped in escape characters which will render the text green in terminals. + Green = color.New(color.Bold, color.FgGreen).SprintfFunc() + // Yellow can be used as Sprintf, where the output it wrapped in escape characters which will render the text yellow in terminals. + Yellow = color.New(color.Bold, color.FgYellow).SprintfFunc() + // Cyan can be used as Sprintf, where the output it wrapped in escape characters which will render the text cyan in terminals. + Cyan = color.New(color.FgCyan).SprintfFunc() +) diff --git a/console/survey.go b/console/survey.go index a732583f..56ecac72 100644 --- a/console/survey.go +++ b/console/survey.go @@ -98,9 +98,8 @@ func (s *Survey) writeTo(m map[string]interface{}, v interface{}) error { func (q *Question) prompt(w io.Writer) { hint := q.defaultHint() - cyan := color.New(color.FgCyan).SprintfFunc() bold := color.New(color.Bold).SprintfFunc() - fmt.Fprintf(w, "%s %s%s: ", cyan("?"), bold("%s", q.Text), hint) + fmt.Fprintf(w, "%s %s%s: ", Cyan("?"), bold("%s", q.Text), hint) } func (q *Question) defaultHint() string { diff --git a/console/symbols.go b/console/symbols.go index 21898184..07fe9500 100644 --- a/console/symbols.go +++ b/console/symbols.go @@ -2,8 +2,6 @@ package console import ( "strings" - - "github.com/fatih/color" ) const ( @@ -12,15 +10,6 @@ const ( checkQ = "?" ) -var ( - // Red can be used as Sprintf, where the output it wrapped in escape characters which will render the text red in terminals. - Red = color.New(color.Bold, color.FgRed).SprintfFunc() - // Green can be used as Sprintf, where the output it wrapped in escape characters which will render the text green in terminals. - Green = color.New(color.Bold, color.FgGreen).SprintfFunc() - // Yellow can be used as Sprintf, where the output it wrapped in escape characters which will render the text yellow in terminals. - Yellow = color.New(color.Bold, color.FgYellow).SprintfFunc() -) - // Checkmark type is a string with some functions attached. type Checkmark string diff --git a/fritz/api_internal.go b/fritz/api_internal.go index 4a408c82..f4194123 100644 --- a/fritz/api_internal.go +++ b/fritz/api_internal.go @@ -2,6 +2,7 @@ package fritz import ( "github.com/bpicode/fritzctl/httpread" + "github.com/pkg/errors" ) // Internal exposes Fritz!Box internal and undocumented API. @@ -9,6 +10,7 @@ type Internal interface { ListLanDevices() (*LanDevices, error) ListLogs() (*MessageLog, error) InternetStats() (*TrafficMonitoringData, error) + BoxInfo() (*BoxData, error) } // NewInternal creates a Fritz/internal API from a given client. @@ -54,6 +56,19 @@ func (i *internal) InternetStats() (*TrafficMonitoringData, error) { return &data[0], err } +// BoxInfo queries metadata from the FRITZ!Box. Data is drawn from: https://fritz.box/cgi-bin/system_status. +func (i *internal) BoxInfo() (*BoxData, error) { + url := i.systemStatus().build() + h := struct { + Body string `xml:"body"` + }{} + err := httpread.XML(i.client.getf(url), &h) + if err != nil { + return nil, errors.Wrap(err, "could not obtain raw system status data") + } + return boxDataParser{}.parse(h.Body), nil +} + func (i *internal) query() fritzURLBuilder { return i.client.query().path(queryURI) } @@ -61,3 +76,7 @@ func (i *internal) query() fritzURLBuilder { func (i *internal) inetStat() fritzURLBuilder { return i.client.query().path(inetStatURI) } + +func (i *internal) systemStatus() fritzURLBuilder { + return i.client.query().path(systemStatusURI) +} diff --git a/fritz/api_internal_test.go b/fritz/api_internal_test.go index 96002bce..cdb700ed 100644 --- a/fritz/api_internal_test.go +++ b/fritz/api_internal_test.go @@ -19,29 +19,32 @@ func TestInternalFritzAPI(t *testing.T) { {testListLanDevices}, {testListLogs}, {testInetStats}, + {testBoxInfo}, } for _, testCase := range testCases { t.Run(fmt.Sprintf("Test aha api %s", runtime.FuncForPC(reflect.ValueOf(testCase.tc).Pointer()).Name()), func(t *testing.T) { srv := mock.New().Start() defer srv.Close() - - client, err := NewClient("../mock/client_config_template.json") - assert.NoError(t, err) - u, err := url.Parse(srv.Server.URL) - assert.NoError(t, err) - client.Config.Net.Protocol = u.Scheme - client.Config.Net.Host = u.Host - - err = client.Login() - assert.NoError(t, err) - - internal := NewInternal(client) + internal := setUpClient(t, srv) assert.NotNil(t, internal) testCase.tc(t, internal) }) } } +func setUpClient(t *testing.T, srv *mock.Fritz) Internal { + client, err := NewClient("../mock/client_config_template.json") + assert.NoError(t, err) + u, err := url.Parse(srv.Server.URL) + assert.NoError(t, err) + client.Config.Net.Protocol = u.Scheme + client.Config.Net.Host = u.Host + err = client.Login() + assert.NoError(t, err) + internal := NewInternal(client) + return internal +} + func testInetStats(t *testing.T, i Internal) { _, err := i.InternetStats() assert.NoError(t, err) @@ -64,3 +67,20 @@ func testListLogs(t *testing.T, i Internal) { assert.Len(t, m, 3) } } + +func testBoxInfo(t *testing.T, i Internal) { + data, err := i.BoxInfo() + assert.NoError(t, err) + assert.NotZero(t, data) +} + +// TestInternal_BoxInfo_WithError test the error path of BoxInfo. +func TestInternal_BoxInfo_WithError(t *testing.T) { + m := mock.New() + m.SystemStatus = "/path/to/nonexistent/file/jsafbjsabfjasb.html" + srv := m.Start() + defer srv.Close() + internal := setUpClient(t, srv) + _, err := internal.BoxInfo() + assert.Error(t, err) +} diff --git a/fritz/box_data.go b/fritz/box_data.go new file mode 100644 index 00000000..67ca150a --- /dev/null +++ b/fritz/box_data.go @@ -0,0 +1,113 @@ +package fritz + +import ( + "fmt" + "strconv" + "strings" + + "github.com/bpicode/fritzctl/stringutils" +) + +// BoxData contains runtime information of the FRITZ!Box. +// codebeat:disable[TOO_MANY_IVARS] +type BoxData struct { + Model Model + FirmwareVersion FirmwareVersion + Runtime Runtime + Hash string + Status string +} + +// Model contains information about the type of the box. +type Model struct { + Name string + Annex string + Branding string +} + +// FirmwareVersion represent the FRITZ!OS version. +type FirmwareVersion struct { + Image string + OsVersionMajor string + OsVersionMinor string + OsVersionRevision string +} + +// Runtime contains data on how long the FRITZ!Box has been running. +type Runtime struct { + Hours uint64 + Days uint64 + Months uint64 + Years uint64 + Reboots uint64 +} + +// codebeat:enable[TOO_MANY_IVARS] + +// String returns a textual representation of Model. +func (m Model) String() string { + return fmt.Sprintf("%s, ADSL standard '%s', branded as '%s'", m.Name, m.Annex, + stringutils.DefaultIfEmpty(m.Branding, "unknown")) +} + +// String returns a textual representation of FirmwareVersion. +func (v FirmwareVersion) String() string { + return fmt.Sprintf("FRITZ!OS %s.%s (%s.%s.%s revision %s)", v.OsVersionMajor, v.OsVersionMinor, + v.Image, v.OsVersionMajor, v.OsVersionMinor, stringutils.DefaultIfEmpty(v.OsVersionRevision, "unknown")) +} + +// String returns a textual representation of Runtime. +func (r Runtime) String() string { + return fmt.Sprintf("%d years, %d months, %d days, %d hours, %d reboots", r.Years, r.Months, r.Days, r.Hours, r.Reboots) +} + +type boxDataParser struct { +} + +func (p boxDataParser) parse(raw string) *BoxData { + trimmed := strings.TrimSpace(raw) + tokens := strings.Split(trimmed, "-") + var tenTokens [10]string + copy(tenTokens[:], tokens) + if len(tokens) > 9 { + tenTokens[9] = strings.Join(tokens[9:], "-") + } + return p.parseTokens(tenTokens) +} + +func (p boxDataParser) parseTokens(tokens [10]string) *BoxData { + var data BoxData + data.Model = p.model(tokens[0], tokens[1], tokens[9]) + data.Runtime = p.runningFor(tokens[2], tokens[3]) + data.Hash = fmt.Sprintf("%s-%s", tokens[4], tokens[5]) + data.Status = tokens[6] + data.FirmwareVersion = p.firmwareVersion(tokens[7], tokens[8]) + return &data +} + +func (p boxDataParser) runningFor(firstToken, secondToken string) Runtime { + var r Runtime + r.Hours, _ = strconv.ParseUint(firstToken[:2], 10, 64) + r.Days, _ = strconv.ParseUint(firstToken[2:4], 10, 64) + r.Months, _ = strconv.ParseUint(firstToken[4:], 10, 64) + r.Years, _ = strconv.ParseUint(secondToken[:2], 10, 64) + r.Reboots, _ = strconv.ParseUint(secondToken[2:], 10, 64) + return r + +} + +func (p boxDataParser) firmwareVersion(version, revision string) FirmwareVersion { + return FirmwareVersion{ + Image: version[0:3], + OsVersionMajor: version[3:5], + OsVersionMinor: version[5:], + OsVersionRevision: revision, + } +} +func (p boxDataParser) model(name, annex, branding string) Model { + return Model{ + Name: name, + Annex: annex, + Branding: branding, + } +} diff --git a/fritz/box_data_test.go b/fritz/box_data_test.go new file mode 100644 index 00000000..e2410e5a --- /dev/null +++ b/fritz/box_data_test.go @@ -0,0 +1,39 @@ +package fritz + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func Test_boxDataParser_parse(t *testing.T) { + tests := []struct { + name string + arg string + }{ + {name: "Fon ata 1020", arg: "FRITZ!Box Fon ata 1020-B-060102-040410-262336-724176-787902-110401-2956-avme-en"}, + {name: "Fon WLAN UI", arg: "FRITZ!Box Fon WLAN (UI)-B-070608-050527-151063-152677-787902-080434-7804-1und1"}, + {name: "Fon WLAN 7170", arg: "FRITZ!Box Fon WLAN 7170-B-171008-000028-457563-147110-787902-290487-19985-avm"}, + {name: "Fon WLAN 7170", arg: "FRITZ!Box Fon WLAN 7170 (UI)-B-171607-041025-521747-370532-147902-290486-19138-1und1"}, + {name: "Fon WLAN 7170", arg: "FRITZ!Box Fon WLAN 7170 Annex A-A-042602-030325-402401-042265-787902-580482"}, + {name: "Fon WLAN 7270", arg: "FRITZ!Box Fon WLAN 7270-B-111700-030716-306463-160202-787902-540480-15918-avm"}, + {name: "Fon WLAN 7270", arg: "FRITZ!Box Fon WLAN 7270 v2-B-071710-020026-055200-026256-147902-540504-20260-avme-en"}, + {name: "Fon WLAN 7340", arg: "FRITZ!Box Fon WLAN 7340-B-072706-000217-533416-737311-147902-990505-20608-avme-en"}, + {name: "Fon WLAN 7390", arg: "FRITZ!Box Fon WLAN 7390-B-161001-000109-670300-000324-787902-840507-21400-avm"}, + {name: "Fon WLAN 7390", arg: "FRITZ!Box Fon WLAN 7390 (UI)-B-171408-010206-436146-332654-147902-840505-20359-1und1"}, + {name: "Fon WLAN 7390", arg: "FRITZ!Box Fon WLAN 7390-A-091301-000006-002567-355146-787902-840505-20608-avme-de"}, + {name: "Repeater NG", arg: "FRITZ!WLAN Repeater N/G-B-152108-020222-445034-614506-787902-680486-20648-avm"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + p := boxDataParser{} + data := p.parse(tt.arg) + assert.NotZero(t, data) + fmt.Println("Model", data.Model) + fmt.Println("Version", data.FirmwareVersion) + fmt.Println("Runtime", data.Runtime) + fmt.Println("----") + }) + } +} diff --git a/fritz/fritzapi_constants.go b/fritz/fritzapi_constants.go index ae3e482f..521e7f75 100644 --- a/fritz/fritzapi_constants.go +++ b/fritz/fritzapi_constants.go @@ -14,6 +14,7 @@ const ( queryURI = "/query.lua" inetStatURI = "/internet/inetstat_monitor.lua" phoneListURI = "/fon_num/foncalls_list.lua" + systemStatusURI = "/cgi-bin/system_status" ) type fritzURLBuilder interface { diff --git a/mock/fritzbox_mock.go b/mock/fritzbox_mock.go index 2cfdb504..5e21c6c0 100644 --- a/mock/fritzbox_mock.go +++ b/mock/fritzbox_mock.go @@ -20,6 +20,7 @@ type Fritz struct { LanDevices string InetStats string PhoneCalls string + SystemStatus string Server *httptest.Server } @@ -35,6 +36,7 @@ func New() *Fritz { LanDevices: "../mock/landevices.json", InetStats: "../mock/traffic.json", PhoneCalls: "../mock/calls.csv", + SystemStatus: "../mock/system_status.html", } } @@ -64,6 +66,7 @@ func (f *Fritz) fritzRoutes() *httprouter.Router { router.GET("/query.lua", f.queryHandler) router.GET("/internet/inetstat_monitor.lua", f.inetStatHandler) router.GET("/fon_num/foncalls_list.lua", f.phoneCallsHandler) + router.GET("/cgi-bin/system_status", f.systemStatusHandler) return router } @@ -129,3 +132,7 @@ func (f *Fritz) preProcess(w http.ResponseWriter, r *http.Request) bool { func (f *Fritz) phoneCallsHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { f.writeFromFs(w, f.PhoneCalls) } + +func (f *Fritz) systemStatusHandler(w http.ResponseWriter, r *http.Request, ps httprouter.Params) { + f.writeFromFs(w, f.SystemStatus) +} diff --git a/mock/fritzbox_mock_test.go b/mock/fritzbox_mock_test.go index 1a1ec94a..b2702a63 100644 --- a/mock/fritzbox_mock_test.go +++ b/mock/fritzbox_mock_test.go @@ -106,6 +106,7 @@ func TestResponseWhenBackendFileNotFound(t *testing.T) { assert5xxResponse(t, r) } +// TestPhoneCalls tests the mocked fritz server. func TestPhoneCalls(t *testing.T) { fritz := New() fritz.Start() @@ -115,6 +116,15 @@ func TestPhoneCalls(t *testing.T) { assert2xxResponse(t, r) } +// TestSystemStatus tests the mocked fritz server. +func TestSystemStatus(t *testing.T) { + fritz := New() + fritz.Start() + defer fritz.Close() + r, _ := (&http.Client{}).Get(fritz.Server.URL + "/cgi-bin/system_status") + assert2xxResponse(t, r) +} + func assert2xxResponse(t *testing.T, r *http.Response) { assert.True(t, r.StatusCode >= 200) assert.True(t, r.StatusCode < 300) diff --git a/mock/system_status.html b/mock/system_status.html new file mode 100644 index 00000000..6099c22e --- /dev/null +++ b/mock/system_status.html @@ -0,0 +1 @@ +FRITZ!Box 7490-B-050103-010027-111111-222222-333333-1130692-47565-avm \ No newline at end of file