From cc5ed5f23e5d5fdfd36e9eee2b5f7c9669d78374 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 28 Oct 2025 18:25:45 +0700 Subject: [PATCH 01/13] Add OpenAPI specs --- .gitignore | 2 + generate.go | 4 + go.mod | 53 +++++ go.sum | 233 ++++++++++++++++++++ oapi-gin.cfg.yaml | 5 + oapi-types.cfg.yaml | 4 + openapi.yaml | 523 ++++++++++++++++++++++++++++++++++++++++++++ 7 files changed, 824 insertions(+) create mode 100644 generate.go create mode 100644 oapi-gin.cfg.yaml create mode 100644 oapi-types.cfg.yaml create mode 100644 openapi.yaml diff --git a/.gitignore b/.gitignore index e147207..612d3ff 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,3 @@ +internal/api/routes.gen.go +internal/api/types.gen.go repeater \ No newline at end of file diff --git a/generate.go b/generate.go new file mode 100644 index 0000000..c01b94b --- /dev/null +++ b/generate.go @@ -0,0 +1,4 @@ +package main + +//go:generate go tool oapi-codegen -config oapi-types.cfg.yaml openapi.yaml +//go:generate go tool oapi-codegen -config oapi-gin.cfg.yaml openapi.yaml diff --git a/go.mod b/go.mod index 0f2fed9..c90ffaa 100644 --- a/go.mod +++ b/go.mod @@ -3,7 +3,60 @@ module git.nemunai.re/nemunaire/repeater go 1.24.4 require ( + github.com/getkin/kin-openapi v0.132.0 + github.com/gin-gonic/gin v1.11.0 github.com/godbus/dbus/v5 v5.1.0 github.com/gorilla/mux v1.8.1 github.com/gorilla/websocket v1.5.3 ) + +require ( + github.com/bytedance/sonic v1.14.0 // indirect + github.com/bytedance/sonic/loader v0.3.0 // indirect + github.com/cloudwego/base64x v0.1.6 // indirect + github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 // indirect + github.com/gabriel-vasile/mimetype v1.4.8 // indirect + github.com/gin-contrib/sse v1.1.0 // indirect + github.com/go-openapi/jsonpointer v0.21.0 // indirect + github.com/go-openapi/swag v0.23.0 // indirect + github.com/go-playground/locales v0.14.1 // indirect + github.com/go-playground/universal-translator v0.18.1 // indirect + github.com/go-playground/validator/v10 v10.27.0 // indirect + github.com/goccy/go-json v0.10.2 // indirect + github.com/goccy/go-yaml v1.18.0 // indirect + github.com/josharian/intern v1.0.0 // indirect + github.com/json-iterator/go v1.1.12 // indirect + github.com/klauspost/cpuid/v2 v2.3.0 // indirect + github.com/leodido/go-urn v1.4.0 // indirect + github.com/mailru/easyjson v0.7.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 // indirect + github.com/modern-go/reflect2 v1.0.2 // indirect + github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 // indirect + github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 // indirect + github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 // indirect + github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 // indirect + github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/perimeterx/marshmallow v1.1.5 // indirect + github.com/quic-go/qpack v0.5.1 // indirect + github.com/quic-go/quic-go v0.54.0 // indirect + github.com/speakeasy-api/jsonpath v0.6.0 // indirect + github.com/speakeasy-api/openapi-overlay v0.10.2 // indirect + github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/ugorji/go/codec v1.3.0 // indirect + github.com/vmware-labs/yaml-jsonpath v0.3.2 // indirect + go.uber.org/mock v0.5.0 // indirect + golang.org/x/arch v0.20.0 // indirect + golang.org/x/crypto v0.40.0 // indirect + golang.org/x/mod v0.25.0 // indirect + golang.org/x/net v0.42.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/sys v0.35.0 // indirect + golang.org/x/text v0.27.0 // indirect + golang.org/x/tools v0.34.0 // indirect + google.golang.org/protobuf v1.36.9 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) + +tool github.com/oapi-codegen/oapi-codegen/v2/cmd/oapi-codegen diff --git a/go.sum b/go.sum index 0546936..17956cd 100644 --- a/go.sum +++ b/go.sum @@ -1,6 +1,239 @@ +github.com/bytedance/sonic v1.14.0 h1:/OfKt8HFw0kh2rj8N0F6C/qPGRESq0BbaNZgcNXXzQQ= +github.com/bytedance/sonic v1.14.0/go.mod h1:WoEbx8WTcFJfzCe0hbmyTGrfjt8PzNEBdxlNUO24NhA= +github.com/bytedance/sonic/loader v0.3.0 h1:dskwH8edlzNMctoruo8FPTJDF3vLtDT0sXZwvZJyqeA= +github.com/bytedance/sonic/loader v0.3.0/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI= +github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI= +github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI= +github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU= +github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M= +github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dprotaso/go-yit v0.0.0-20191028211022-135eb7262960/go.mod h1:9HQzr9D/0PGwMEbC3d5AB7oi67+h4TsQqItC1GVYG58= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936 h1:PRxIJD8XjimM5aTknUK9w6DHLDox2r2M3DI4i2pnd3w= +github.com/dprotaso/go-yit v0.0.0-20220510233725-9ba8df137936/go.mod h1:ttYvX5qlB+mlV1okblJqcSMtR4c52UKxDiX9GRBS8+Q= +github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo= +github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4= +github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ= +github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM= +github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8= +github.com/getkin/kin-openapi v0.132.0 h1:3ISeLMsQzcb5v26yeJrBcdTCEQTag36ZjaGk7MIRUwk= +github.com/getkin/kin-openapi v0.132.0/go.mod h1:3OlG51PCYNsPByuiMB0t4fjnNlIDnaEDsjiKUV8nL58= +github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w= +github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM= +github.com/gin-gonic/gin v1.11.0 h1:OW/6PLjyusp2PPXtyxKHU0RbX6I/l28FTdDlae5ueWk= +github.com/gin-gonic/gin v1.11.0/go.mod h1:+iq/FyxlGzII0KHiBGjuNn4UNENUlKbGlNmc+W50Dls= +github.com/go-openapi/jsonpointer v0.21.0 h1:YgdVicSA9vH5RiHs9TZW5oyafXZFc6+2Vc1rr/O9oNQ= +github.com/go-openapi/jsonpointer v0.21.0/go.mod h1:IUyH9l/+uyhIYQ/PXVA41Rexl+kOkAPDdXEYns6fzUY= +github.com/go-openapi/swag v0.23.0 h1:vsEVJDUo2hPJ2tu0/Xc+4noaxyEffXNIs3cOULZ+GrE= +github.com/go-openapi/swag v0.23.0/go.mod h1:esZ8ITTYEsH1V2trKHjAN8Ai7xHb8RV+YSZ577vPjgQ= +github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= +github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4= +github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA= +github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY= +github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY= +github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY= +github.com/go-playground/validator/v10 v10.27.0 h1:w8+XrWVMhGkxOaaowyKH35gFydVHOvC0/uWoy2Fzwn4= +github.com/go-playground/validator/v10 v10.27.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo= +github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE= +github.com/go-test/deep v1.0.8 h1:TDsG77qcSprGbC6vTN8OuXp5g+J+b5Pcguhf7Zt61VM= +github.com/go-test/deep v1.0.8/go.mod h1:5C2ZWiW0ErCdrYzpqxLbTX7MG14M9iiw8DgHncVwcsE= +github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= +github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= +github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= +github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= +github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= +github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs= +github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w= +github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0= +github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI= +github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= +github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= +github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= +github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg= +github.com/google/pprof v0.0.0-20210407192527-94a9f03dee38/go.mod h1:kpwsk12EmLew5upagYY7GY0pfYCcupk39gWOCRROcvE= github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY= github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU= +github.com/ianlancetaylor/demangle v0.0.0-20200824232613-28f6c0f3b639/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc= +github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8HmY= +github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y= +github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM= +github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= +github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= +github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ= +github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI= +github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= +github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421 h1:ZqeYNhU3OHLH3mGKHDcjJRFFRrJa6eAM5H+CtDdOsPc= +github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= +github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M= +github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826 h1:RWengNIwukTxcDr9M+97sNutRR1RKhG96O6jWumTTnw= +github.com/mohae/deepcopy v0.0.0-20170929034955-c48cc78d4826/go.mod h1:TaXosZuwdSHYgviHp1DAtfrULt5eUgsSMsZf+YrPgl8= +github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A= +github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE= +github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU= +github.com/oapi-codegen/oapi-codegen/v2 v2.5.0 h1:iJvF8SdB/3/+eGOXEpsWkD8FQAHj6mqkb6Fnsoc8MFU= +github.com/oapi-codegen/oapi-codegen/v2 v2.5.0/go.mod h1:fwlMxUEMuQK5ih9aymrxKPQqNm2n8bdLk1ppjH+lr9w= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037 h1:G7ERwszslrBzRxj//JalHPu/3yz+De2J+4aLtSRlHiY= +github.com/oasdiff/yaml v0.0.0-20250309154309-f31be36b4037/go.mod h1:2bpvgLBZEtENV5scfDFEtB/5+1M4hkQhDQrccEJ/qGw= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90 h1:bQx3WeLcUWy+RletIKwUIt4x3t8n2SxavmoclizMb8c= +github.com/oasdiff/yaml3 v0.0.0-20250309153720-d2182401db90/go.mod h1:y5+oSEHCPT/DGrS++Wc/479ERge0zTFxaF8PbGKcg2o= +github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.10.2/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE= +github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk= +github.com/onsi/ginkgo v1.16.4 h1:29JGrr5oVBm5ulCWet69zQkzWipVXIol6ygQUe/EzNc= +github.com/onsi/ginkgo v1.16.4/go.mod h1:dX+/inL/fNMqNlz0e9LfyB9TswhZpCVdJM/Z6Vvnwo0= +github.com/onsi/ginkgo/v2 v2.1.3/go.mod h1:vw5CSIxN1JObi/U8gcbwft7ZxR2dgaR70JSE3/PpL4c= +github.com/onsi/gomega v1.7.0/go.mod h1:ex+gbHU/CVuBBDIJjb2X0qEXbFg53c61hWP/1CpauHY= +github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY= +github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo= +github.com/onsi/gomega v1.17.0/go.mod h1:HnhC7FXeEQY45zxNK3PPoIUhzk/80Xly9PcubAlGdZY= +github.com/onsi/gomega v1.19.0 h1:4ieX6qQjPP/BfC3mpsAtIGGlxTWPeA3Inl/7DtXw1tw= +github.com/onsi/gomega v1.19.0/go.mod h1:LY+I3pBVzYsTBU1AnDwOSxaYi9WoWiqgwooUqq9yPro= +github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= +github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/perimeterx/marshmallow v1.1.5 h1:a2LALqQ1BlHM8PZblsDdidgv1mWi1DgC2UmX50IvK2s= +github.com/perimeterx/marshmallow v1.1.5/go.mod h1:dsXbUu8CRzfYP5a87xpp0xq9S3u0Vchtcl8we9tYaXw= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/quic-go/qpack v0.5.1 h1:giqksBPnT/HDtZ6VhtFKgoLOWmlyo9Ei6u9PqzIMbhI= +github.com/quic-go/qpack v0.5.1/go.mod h1:+PC4XFrEskIVkcLzpEkbLqq1uCoxPhQuvK5rH1ZgaEg= +github.com/quic-go/quic-go v0.54.0 h1:6s1YB9QotYI6Ospeiguknbp2Znb/jZYjZLRXn9kMQBg= +github.com/quic-go/quic-go v0.54.0/go.mod h1:e68ZEaCdyviluZmy44P6Iey98v/Wfz6HCjQEm+l8zTY= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/sergi/go-diff v1.1.0 h1:we8PVUC3FE2uYfodKH/nBHMSetSfHDR6scGdBi+erh0= +github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/speakeasy-api/jsonpath v0.6.0 h1:IhtFOV9EbXplhyRqsVhHoBmmYjblIRh5D1/g8DHMXJ8= +github.com/speakeasy-api/jsonpath v0.6.0/go.mod h1:ymb2iSkyOycmzKwbEAYPJV/yi2rSmvBCLZJcyD+VVWw= +github.com/speakeasy-api/openapi-overlay v0.10.2 h1:VOdQ03eGKeiHnpb1boZCGm7x8Haj6gST0P3SGTX95GU= +github.com/speakeasy-api/openapi-overlay v0.10.2/go.mod h1:n0iOU7AqKpNFfEt6tq7qYITC4f0yzVVdFw0S7hukemg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= +github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI= +github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08= +github.com/ugorji/go/codec v1.3.0 h1:Qd2W2sQawAfG8XSvzwhBeoGq71zXOC/Q1E9y/wUcsUA= +github.com/ugorji/go/codec v1.3.0/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4= +github.com/vmware-labs/yaml-jsonpath v0.3.2 h1:/5QKeCBGdsInyDCyVNLbXyilb61MXGi9NP674f9Hobk= +github.com/vmware-labs/yaml-jsonpath v0.3.2/go.mod h1:U6whw1z03QyqgWdgXxvVnQ90zN1BWz5V+51Ewf8k+rQ= +github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74= +go.uber.org/mock v0.5.0 h1:KAMbZvZPyBPWgD14IrIQ38QCyjwpvVVV6K/bHl1IwQU= +go.uber.org/mock v0.5.0/go.mod h1:ge71pBPLYDk7QIi1LupWxdAykm7KIEFchiOqd6z7qMM= +golang.org/x/arch v0.20.0 h1:dx1zTU0MAE98U+TQ8BLl7XsJbgze2WnNKF/8tGp/Q6c= +golang.org/x/arch v0.20.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI= +golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto= +golang.org/x/crypto v0.40.0 h1:r4x+VvoG5Fm+eJcxMaY8CQM7Lb0l1lsmjGBQ6s8BfKM= +golang.org/x/crypto v0.40.0/go.mod h1:Qr1vMER5WyS2dfPHAlsOj01wgLbsyWtFn/aY+5+ZdxY= +golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA= +golang.org/x/mod v0.25.0 h1:n7a+ZbQKQA/Ysbyb0/6IbB1H/X41mKgbhfv7AfG/44w= +golang.org/x/mod v0.25.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= +golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg= +golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= +golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A= +golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU= +golang.org/x/net v0.0.0-20210428140749-89ef3d95e781/go.mod h1:OJAsFXCWl8Ukc7SiCT/9KSuxbyM7479/AVlXFRxuMCk= +golang.org/x/net v0.0.0-20220225172249-27dd8689420f/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= +golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= +golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= +golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= +golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.0.0-20211216021012-1d35b9e2eb4e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.35.0 h1:vz1N37gP5bs89s7He8XuIYXpyY0+QlsKmzipCbUtyxI= +golang.org/x/sys v0.35.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= +golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= +golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= +golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= +golang.org/x/tools v0.34.0 h1:qIpSLOxeCYGg9TrcJokLBG4KFA6d795g0xkBkiESGlo= +golang.org/x/tools v0.34.0/go.mod h1:pAP9OwEaY1CAW3HOmg3hLZC5Z0CCmzjAF2UQMSqNARg= +golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8= +google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0= +google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM= +google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE= +google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo= +google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU= +google.golang.org/protobuf v1.26.0-rc.1/go.mod h1:jlhhOSvTdKEhbULTjvd4ARK9grFBp09yW+WbY/TyQbw= +google.golang.org/protobuf v1.26.0/go.mod h1:9q0QmTI4eRPtz6boOQmLYwt+qCgq0jsYwAQnmE0givc= +google.golang.org/protobuf v1.36.9 h1:w2gp2mA27hUeUzj9Ex9FBjsBm40zfaDtEWow293U7Iw= +google.golang.org/protobuf v1.36.9/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ= +gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw= +gopkg.in/yaml.v2 v2.2.1/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20191026110619-0b21df46bc1d/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/oapi-gin.cfg.yaml b/oapi-gin.cfg.yaml new file mode 100644 index 0000000..b30fef2 --- /dev/null +++ b/oapi-gin.cfg.yaml @@ -0,0 +1,5 @@ +package: api +generate: + - gin + - embedded-spec +output: internal/api/routes.gen.go diff --git a/oapi-types.cfg.yaml b/oapi-types.cfg.yaml new file mode 100644 index 0000000..57b44d2 --- /dev/null +++ b/oapi-types.cfg.yaml @@ -0,0 +1,4 @@ +package: api +generate: + - types +output: internal/api/types.gen.go diff --git a/openapi.yaml b/openapi.yaml new file mode 100644 index 0000000..d1ee08e --- /dev/null +++ b/openapi.yaml @@ -0,0 +1,523 @@ +openapi: 3.0.3 +info: + title: Travel Router Control API + description: | + API for controlling a mini travel router with dual WiFi interfaces and Ethernet connectivity. + The router can operate as a WiFi repeater, connecting to upstream networks while providing + a hotspot for client devices. + version: 1.0.0 + contact: + name: API Support + license: + name: MIT + +servers: + - url: http://localhost:8080 + description: Local router API + +tags: + - name: WiFi + description: WiFi client operations (upstream network connection) + - name: Hotspot + description: Access point operations (client-facing hotspot) + - name: Devices + description: Connected devices management + - name: System + description: System status and monitoring + - name: Logs + description: System logs and real-time monitoring + +paths: + /api/wifi/scan: + get: + tags: + - WiFi + summary: Scan for available WiFi networks + description: | + Triggers a WiFi scan using wpa_supplicant via D-Bus and returns all discovered networks + sorted by signal strength. The scan takes approximately 2 seconds to complete. + operationId: scanWiFi + responses: + '200': + description: Successfully scanned networks + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/WiFiNetwork' + example: + - ssid: "Hotel-Guest" + signal: 5 + security: "WPA2" + channel: 6 + bssid: "aa:bb:cc:dd:ee:ff" + - ssid: "Public-WiFi" + signal: 3 + security: "Open" + channel: 11 + bssid: "11:22:33:44:55:66" + '500': + description: Scan error + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /api/wifi/connect: + post: + tags: + - WiFi + summary: Connect to a WiFi network + description: | + Connects the router to an upstream WiFi network using wpa_supplicant. + Supports both open and password-protected networks (WPA/WPA2). + operationId: connectWiFi + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/WiFiConnectRequest' + examples: + protected: + summary: WPA2 protected network + value: + ssid: "Hotel-Guest" + password: "guest1234" + open: + summary: Open network + value: + ssid: "Public-WiFi" + password: "" + responses: + '200': + description: Successfully connected + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + '400': + description: Invalid request data + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Connection failed + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /api/wifi/disconnect: + post: + tags: + - WiFi + summary: Disconnect from WiFi network + description: | + Disconnects from the currently connected upstream WiFi network + and removes all saved network configurations. + operationId: disconnectWiFi + responses: + '200': + description: Successfully disconnected + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + '500': + description: Disconnection failed + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /api/hotspot/config: + post: + tags: + - Hotspot + summary: Configure hotspot settings + description: | + Updates the hotspot (access point) configuration including SSID, password, + and WiFi channel. Changes are written to hostapd configuration file. + The hotspot needs to be restarted for changes to take effect. + operationId: configureHotspot + requestBody: + required: true + content: + application/json: + schema: + $ref: '#/components/schemas/HotspotConfig' + responses: + '200': + description: Configuration updated successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + '400': + description: Invalid configuration data + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + '500': + description: Configuration update failed + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /api/hotspot/toggle: + post: + tags: + - Hotspot + summary: Toggle hotspot on/off + description: | + Enables or disables the hotspot (access point) by starting/stopping + the hostapd service. Returns the new enabled state. + operationId: toggleHotspot + responses: + '200': + description: Hotspot state changed successfully + content: + application/json: + schema: + type: object + properties: + enabled: + type: boolean + description: Current hotspot state after toggle + required: + - enabled + example: + enabled: true + '500': + description: Failed to change hotspot state + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /api/devices: + get: + tags: + - Devices + summary: Get connected devices + description: | + Returns a list of all devices currently connected to the hotspot. + Device information is gathered from DHCP leases and ARP tables. + Only devices with active ARP entries are considered connected. + operationId: getDevices + responses: + '200': + description: List of connected devices + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/ConnectedDevice' + example: + - name: "iPhone-12" + type: "mobile" + mac: "aa:bb:cc:11:22:33" + ip: "192.168.1.100" + - name: "MacBook-Pro" + type: "laptop" + mac: "dd:ee:ff:44:55:66" + ip: "192.168.1.101" + '500': + description: Failed to retrieve device list + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + + /api/status: + get: + tags: + - System + summary: Get system status + description: | + Returns comprehensive system status including WiFi connection state, + hotspot status, connected device count, data usage, and uptime. + operationId: getStatus + responses: + '200': + description: Current system status + content: + application/json: + schema: + $ref: '#/components/schemas/SystemStatus' + + /api/logs: + get: + tags: + - Logs + summary: Get system logs + description: | + Returns the last 100 log entries from the system. + For real-time log streaming, use the WebSocket endpoint. + operationId: getLogs + responses: + '200': + description: List of log entries + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/LogEntry' + example: + - timestamp: "2025-10-28T14:32:10Z" + source: "WiFi" + message: "Scan terminé - 5 réseaux trouvés" + - timestamp: "2025-10-28T14:32:15Z" + source: "WiFi" + message: "Tentative de connexion à Hotel-Guest" + delete: + tags: + - Logs + summary: Clear system logs + description: Clears all stored log entries (keeps only the "logs cleared" entry) + operationId: clearLogs + responses: + '200': + description: Logs cleared successfully + content: + application/json: + schema: + $ref: '#/components/schemas/SuccessResponse' + + /ws/logs: + get: + tags: + - Logs + summary: WebSocket for real-time logs + description: | + WebSocket endpoint for receiving real-time log updates. + Upon connection, all existing logs are sent, followed by new logs as they occur. + + This is a WebSocket endpoint - upgrade the HTTP connection to WebSocket protocol. + operationId: logsWebSocket + responses: + '101': + description: WebSocket connection established + '400': + description: WebSocket upgrade failed + +components: + schemas: + WiFiNetwork: + type: object + description: Discovered WiFi network information + properties: + ssid: + type: string + description: Network SSID (name) + example: "Hotel-Guest" + signal: + type: integer + description: Signal strength (1-5 scale) + minimum: 1 + maximum: 5 + example: 4 + security: + type: string + description: Security type + enum: + - Open + - WEP + - WPA + - WPA2 + example: "WPA2" + channel: + type: integer + description: WiFi channel number + minimum: 1 + maximum: 165 + example: 6 + bssid: + type: string + description: Access point MAC address + pattern: '^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$' + example: "aa:bb:cc:dd:ee:ff" + required: + - ssid + - signal + - security + - channel + - bssid + + WiFiConnectRequest: + type: object + description: Request to connect to a WiFi network + properties: + ssid: + type: string + description: Network SSID to connect to + example: "Hotel-Guest" + password: + type: string + description: Network password (empty string for open networks) + example: "guest1234" + required: + - ssid + - password + + HotspotConfig: + type: object + description: Hotspot (access point) configuration + properties: + ssid: + type: string + description: Hotspot SSID (network name) + minLength: 1 + maxLength: 32 + example: "TravelRouter" + password: + type: string + description: WPA2 password (minimum 8 characters) + minLength: 8 + maxLength: 63 + example: "secure123" + channel: + type: integer + description: WiFi channel (1-11 for 2.4GHz) + minimum: 1 + maximum: 14 + example: 6 + required: + - ssid + - password + - channel + + ConnectedDevice: + type: object + description: Device connected to the hotspot + properties: + name: + type: string + description: Device hostname + example: "iPhone-12" + type: + type: string + description: Detected device type + enum: + - mobile + - tablet + - laptop + - desktop + - unknown + example: "mobile" + mac: + type: string + description: Device MAC address + pattern: '^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$' + example: "aa:bb:cc:11:22:33" + ip: + type: string + description: Assigned IP address + pattern: '^(?:[0-9]{1,3}\.){3}[0-9]{1,3}$' + example: "192.168.1.100" + required: + - name + - type + - mac + - ip + + SystemStatus: + type: object + description: Overall system status + properties: + connected: + type: boolean + description: Whether router is connected to upstream WiFi + example: true + connectedSSID: + type: string + description: SSID of connected upstream network (empty if not connected) + example: "Hotel-Guest" + hotspotEnabled: + type: boolean + description: Whether the hotspot is currently enabled + example: true + connectedCount: + type: integer + description: Number of devices connected to hotspot + minimum: 0 + example: 3 + dataUsage: + type: number + format: double + description: Total data usage in MB (placeholder for future implementation) + example: 145.7 + uptime: + type: integer + format: int64 + description: System uptime in seconds + example: 3600 + connectedDevices: + type: array + description: List of devices connected to hotspot + items: + $ref: '#/components/schemas/ConnectedDevice' + required: + - connected + - connectedSSID + - hotspotEnabled + - connectedCount + - dataUsage + - uptime + - connectedDevices + + LogEntry: + type: object + description: System log entry + properties: + timestamp: + type: string + format: date-time + description: When the log entry was created + example: "2025-10-28T14:32:10Z" + source: + type: string + description: Log source component + enum: + - Système + - WiFi + - Hotspot + example: "WiFi" + message: + type: string + description: Log message + example: "Scan terminé - 5 réseaux trouvés" + required: + - timestamp + - source + - message + + SuccessResponse: + type: object + description: Generic success response + properties: + status: + type: string + enum: + - success + example: "success" + required: + - status + + Error: + type: object + description: Error response + properties: + error: + type: string + description: Error message + example: "Erreur lors du scan WiFi" + required: + - error From b1b9eaa028f57003127595e8cdf62454c48ff660 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 28 Oct 2025 19:23:27 +0700 Subject: [PATCH 02/13] Migrate to a better architectured project --- .gitignore | 2 +- Makefile | 32 ++ README.md | 102 ++++ cmd/repeater/main.go | 40 ++ cmd/repeater/static/app.js | 535 +++++++++++++++++++ cmd/repeater/static/index.html | 273 ++++++++++ cmd/repeater/static/style.css | 788 +++++++++++++++++++++++++++ go.mod | 2 +- internal/api/handlers/handlers.go | 132 +++++ internal/api/handlers/websocket.go | 38 ++ internal/api/router.go | 64 +++ internal/app/app.go | 102 ++++ internal/device/device.go | 132 +++++ internal/hotspot/hotspot.go | 47 ++ internal/logging/logging.go | 94 ++++ internal/models/models.go | 58 ++ internal/wifi/wifi.go | 273 ++++++++++ main.go | 826 ----------------------------- static/app.js | 239 --------- static/index.html | 135 ----- static/style.css | 338 ------------ 21 files changed, 2712 insertions(+), 1540 deletions(-) create mode 100644 Makefile create mode 100644 README.md create mode 100644 cmd/repeater/main.go create mode 100644 cmd/repeater/static/app.js create mode 100644 cmd/repeater/static/index.html create mode 100644 cmd/repeater/static/style.css create mode 100644 internal/api/handlers/handlers.go create mode 100644 internal/api/handlers/websocket.go create mode 100644 internal/api/router.go create mode 100644 internal/app/app.go create mode 100644 internal/device/device.go create mode 100644 internal/hotspot/hotspot.go create mode 100644 internal/logging/logging.go create mode 100644 internal/models/models.go create mode 100644 internal/wifi/wifi.go delete mode 100644 main.go delete mode 100644 static/app.js delete mode 100644 static/index.html delete mode 100644 static/style.css diff --git a/.gitignore b/.gitignore index 612d3ff..ea33d90 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ internal/api/routes.gen.go internal/api/types.gen.go -repeater \ No newline at end of file +/repeater \ No newline at end of file diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..2d57547 --- /dev/null +++ b/Makefile @@ -0,0 +1,32 @@ +.PHONY: build run clean install test + +BINARY_NAME=repeater +CMD_PATH=./cmd/repeater +BUILD_DIR=. + +build: + go build -v -o $(BUILD_DIR)/$(BINARY_NAME) $(CMD_PATH) + +run: build + sudo ./$(BINARY_NAME) + +clean: + go clean + rm -f $(BUILD_DIR)/$(BINARY_NAME) + +install: build + sudo install -m 755 $(BUILD_DIR)/$(BINARY_NAME) /usr/local/bin/ + +test: + go test -v ./... + +tidy: + go mod tidy + +fmt: + go fmt ./... + +vet: + go vet ./... + +all: fmt vet build diff --git a/README.md b/README.md new file mode 100644 index 0000000..54691f9 --- /dev/null +++ b/README.md @@ -0,0 +1,102 @@ +# Travel Router Control + +A Go application for controlling a mini travel router with dual WiFi interfaces and Ethernet connectivity. The router can operate as a WiFi repeater, connecting to upstream networks while providing a hotspot for client devices. + +## Features + +- WiFi network scanning and connection management +- Hotspot (access point) configuration and control +- Connected device monitoring +- Real-time system logs via WebSocket +- RESTful API following OpenAPI 3.0 specification +- Web interface for easy management + +## Architecture + +The application follows a clean architecture pattern: + +``` +. +├── cmd/ +│ └── repeater/ # Application entry point +│ ├── main.go +│ └── static/ # Embedded web assets +├── internal/ +│ ├── api/ # HTTP API layer +│ │ ├── router.go # Gin router setup +│ │ └── handlers/ # HTTP handlers +│ ├── app/ # Application logic & lifecycle +│ ├── device/ # Device management +│ ├── hotspot/ # Hotspot control +│ ├── logging/ # Logging system +│ ├── models/ # Data structures +│ └── wifi/ # WiFi operations (wpa_supplicant via D-Bus) +├── openapi.yaml # API specification +└── go.mod +``` + +## Building + +```bash +go build -o repeater ./cmd/repeater +``` + +## Running + +```bash +sudo ./repeater +``` + +The application requires root privileges to: +- Access D-Bus system bus for wpa_supplicant +- Control systemd services (hostapd) +- Read DHCP leases and ARP tables + +The server will start on port 8080. + +## API Endpoints + +### WiFi Operations +- `GET /api/wifi/scan` - Scan for available networks +- `POST /api/wifi/connect` - Connect to a network +- `POST /api/wifi/disconnect` - Disconnect from current network + +### Hotspot Operations +- `POST /api/hotspot/config` - Configure hotspot settings +- `POST /api/hotspot/toggle` - Enable/disable hotspot + +### Device Management +- `GET /api/devices` - Get connected devices + +### System +- `GET /api/status` - Get system status +- `GET /api/logs` - Get system logs +- `DELETE /api/logs` - Clear logs + +### WebSocket +- `GET /ws/logs` - Real-time log streaming + +See `openapi.yaml` for complete API documentation. + +## Configuration + +The application uses the following system resources: + +- **WiFi Interface**: `wlan0` (for upstream connection) +- **AP Interface**: `wlan1` (for hotspot) +- **Hostapd Config**: `/etc/hostapd/hostapd.conf` +- **WPA Supplicant Config**: `/etc/wpa_supplicant/wpa_supplicant.conf` + +These can be modified in the respective package constants. + +## Dependencies + +- **Gin**: HTTP web framework +- **godbus**: D-Bus client for wpa_supplicant control +- **gorilla/websocket**: WebSocket support +- **wpa_supplicant**: WiFi connection management +- **hostapd**: Hotspot functionality + +## License + +MIT diff --git a/cmd/repeater/main.go b/cmd/repeater/main.go new file mode 100644 index 0000000..20752c8 --- /dev/null +++ b/cmd/repeater/main.go @@ -0,0 +1,40 @@ +package main + +import ( + "embed" + "log" + "os" + "os/signal" + "syscall" + + "github.com/nemunaire/repeater/internal/app" +) + +//go:embed all:static +var assets embed.FS + +func main() { + // Create application instance + application := app.New(assets) + + // Initialize the application + if err := application.Initialize(); err != nil { + log.Fatalf("Failed to initialize application: %v", err) + } + defer application.Shutdown() + + // Handle graceful shutdown + sigChan := make(chan os.Signal, 1) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + go func() { + <-sigChan + log.Println("Shutting down gracefully...") + application.Shutdown() + os.Exit(0) + }() + + // Start the server + if err := application.Run(":8080"); err != nil { + log.Fatalf("Failed to start server: %v", err) + } +} diff --git a/cmd/repeater/static/app.js b/cmd/repeater/static/app.js new file mode 100644 index 0000000..bd29eec --- /dev/null +++ b/cmd/repeater/static/app.js @@ -0,0 +1,535 @@ +// Application state +const appState = { + selectedWifi: null, + hotspotEnabled: true, + autoScrollLogs: true, + ws: null, + reconnectAttempts: 0, + maxReconnectAttempts: 5 +}; + +// Initialize the application +document.addEventListener('DOMContentLoaded', function() { + initializeApp(); +}); + +async function initializeApp() { + console.log('Initializing Travel Router Control Panel...'); + + // Load initial data + await Promise.all([ + loadStatus(), + scanWifi(), + loadDevices(), + loadLogs() + ]); + + // Set up WebSocket for real-time logs + connectWebSocket(); + + // Start periodic updates + startPeriodicUpdates(); + + showToast('success', 'Connecté', 'Interface web chargée avec succès'); +} + +// ===== API Functions ===== + +async function loadStatus() { + try { + const response = await fetch('/api/status'); + const status = await response.json(); + + updateStatusDisplay(status); + return status; + } catch (error) { + console.error('Error loading status:', error); + showToast('error', 'Erreur', 'Impossible de charger le statut'); + } +} + +async function scanWifi() { + const wifiList = document.getElementById('wifiList'); + const scanBtn = document.querySelector('[onclick="scanWifi()"]'); + + if (scanBtn) { + scanBtn.disabled = true; + } + + wifiList.innerHTML = '
Recherche des réseaux disponibles...
'; + + try { + const response = await fetch('/api/wifi/scan'); + const networks = await response.json(); + + displayWifiNetworks(networks); + showToast('success', 'Scan WiFi', `${networks.length} réseau(x) trouvé(s)`); + } catch (error) { + console.error('Error scanning WiFi:', error); + wifiList.innerHTML = '
Erreur lors du scan
'; + showToast('error', 'Erreur', 'Échec du scan WiFi'); + } finally { + if (scanBtn) { + scanBtn.disabled = false; + } + } +} + +async function connectToWifi() { + if (!appState.selectedWifi) { + showToast('warning', 'Attention', 'Veuillez sélectionner un réseau WiFi'); + return; + } + + const password = document.getElementById('wifiPassword').value; + + if (appState.selectedWifi.security !== 'Open' && !password) { + showToast('warning', 'Attention', 'Mot de passe requis pour ce réseau'); + return; + } + + showLoading(true); + + try { + const response = await fetch('/api/wifi/connect', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ssid: appState.selectedWifi.ssid, + password: password + }) + }); + + const result = await response.json(); + + if (response.ok) { + showToast('success', 'Connecté', `Connexion établie avec ${appState.selectedWifi.ssid}`); + document.getElementById('wifiPassword').value = ''; + await loadStatus(); + } else { + throw new Error(result.error || 'Connection failed'); + } + } catch (error) { + console.error('Error connecting to WiFi:', error); + showToast('error', 'Erreur', 'Échec de la connexion: ' + error.message); + } finally { + showLoading(false); + } +} + +async function disconnectWifi() { + showLoading(true); + + try { + const response = await fetch('/api/wifi/disconnect', { + method: 'POST' + }); + + if (response.ok) { + showToast('success', 'Déconnecté', 'Déconnexion WiFi réussie'); + await loadStatus(); + } else { + throw new Error('Disconnection failed'); + } + } catch (error) { + console.error('Error disconnecting WiFi:', error); + showToast('error', 'Erreur', 'Échec de la déconnexion'); + } finally { + showLoading(false); + } +} + +async function updateHotspot() { + const ssid = document.getElementById('hotspotName').value; + const password = document.getElementById('hotspotPassword').value; + const channel = parseInt(document.getElementById('hotspotChannel').value); + + if (!ssid || ssid.length > 32) { + showToast('warning', 'Attention', 'SSID invalide (1-32 caractères)'); + return; + } + + if (!password || password.length < 8 || password.length > 63) { + showToast('warning', 'Attention', 'Mot de passe invalide (8-63 caractères)'); + return; + } + + showLoading(true); + + try { + const response = await fetch('/api/hotspot/config', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ssid, password, channel }) + }); + + if (response.ok) { + showToast('success', 'Configuration', 'Hotspot configuré avec succès'); + } else { + throw new Error('Configuration failed'); + } + } catch (error) { + console.error('Error configuring hotspot:', error); + showToast('error', 'Erreur', 'Échec de la configuration'); + } finally { + showLoading(false); + } +} + +async function toggleHotspot() { + const toggle = document.getElementById('hotspotToggle'); + const enabled = toggle.checked; + + showLoading(true); + + try { + const response = await fetch('/api/hotspot/toggle', { + method: 'POST' + }); + + const result = await response.json(); + + if (response.ok) { + appState.hotspotEnabled = result.enabled; + toggle.checked = result.enabled; + showToast('success', 'Hotspot', result.enabled ? 'Hotspot activé' : 'Hotspot désactivé'); + await loadStatus(); + } else { + toggle.checked = !enabled; + throw new Error('Toggle failed'); + } + } catch (error) { + console.error('Error toggling hotspot:', error); + toggle.checked = !enabled; + showToast('error', 'Erreur', 'Échec du basculement'); + } finally { + showLoading(false); + } +} + +async function loadDevices() { + try { + const response = await fetch('/api/devices'); + const devices = await response.json(); + + displayDevices(devices); + document.getElementById('deviceCount').textContent = devices.length; + } catch (error) { + console.error('Error loading devices:', error); + const devicesList = document.getElementById('devicesList'); + devicesList.innerHTML = '

Erreur de chargement

'; + } +} + +async function loadLogs() { + try { + const response = await fetch('/api/logs'); + const logs = await response.json(); + + const logContainer = document.getElementById('logContainer'); + logContainer.innerHTML = ''; + + if (logs.length === 0) { + logContainer.innerHTML = '

Aucun log disponible

'; + } else { + logs.forEach(log => addLogEntry(log)); + } + } catch (error) { + console.error('Error loading logs:', error); + } +} + +async function clearLogs() { + try { + const response = await fetch('/api/logs', { method: 'DELETE' }); + + if (response.ok) { + const logContainer = document.getElementById('logContainer'); + logContainer.innerHTML = '

Aucun log disponible

'; + showToast('success', 'Logs', 'Logs effacés'); + } + } catch (error) { + console.error('Error clearing logs:', error); + showToast('error', 'Erreur', 'Échec de la suppression des logs'); + } +} + +// ===== Display Functions ===== + +function updateStatusDisplay(status) { + // Update WiFi status badge + const wifiStatus = document.getElementById('wifiStatus'); + const wifiDot = wifiStatus.querySelector('.status-dot'); + const wifiText = wifiStatus.querySelector('.status-text'); + + if (status.connected) { + wifiDot.className = 'status-dot active'; + wifiText.textContent = `Connecté: ${status.connectedSSID}`; + } else { + wifiDot.className = 'status-dot offline'; + wifiText.textContent = 'Déconnecté'; + } + + // Update hotspot status badge + const hotspotStatus = document.getElementById('hotspotStatus'); + const hotspotDot = hotspotStatus.querySelector('.status-dot'); + const hotspotText = hotspotStatus.querySelector('.status-text'); + const hotspotToggle = document.getElementById('hotspotToggle'); + + if (status.hotspotEnabled) { + hotspotDot.className = 'status-dot active'; + hotspotText.textContent = 'Hotspot actif'; + hotspotToggle.checked = true; + } else { + hotspotDot.className = 'status-dot offline'; + hotspotText.textContent = 'Hotspot inactif'; + hotspotToggle.checked = false; + } + + appState.hotspotEnabled = status.hotspotEnabled; + + // Update stats + document.getElementById('connectedDevices').textContent = status.connectedCount; + document.getElementById('dataUsage').textContent = `${status.dataUsage.toFixed(1)} MB`; + document.getElementById('uptime').textContent = formatUptime(status.uptime); + document.getElementById('currentNetwork').textContent = status.connectedSSID || '-'; +} + +function displayWifiNetworks(networks) { + const wifiList = document.getElementById('wifiList'); + wifiList.innerHTML = ''; + + if (networks.length === 0) { + wifiList.innerHTML = '
Aucun réseau trouvé
'; + return; + } + + networks.forEach(network => { + const wifiItem = document.createElement('div'); + wifiItem.className = 'wifi-item'; + wifiItem.onclick = () => selectWifi(network, wifiItem); + + wifiItem.innerHTML = ` +
+
${escapeHtml(network.ssid)}
+
+ ${network.security} + Canal ${network.channel} + ${network.bssid} +
+
+
+ ${generateSignalBars(network.signal)} +
+ `; + + wifiList.appendChild(wifiItem); + }); +} + +function selectWifi(network, element) { + document.querySelectorAll('.wifi-item').forEach(item => item.classList.remove('selected')); + element.classList.add('selected'); + appState.selectedWifi = network; +} + +function displayDevices(devices) { + const devicesList = document.getElementById('devicesList'); + devicesList.innerHTML = ''; + + if (devices.length === 0) { + devicesList.innerHTML = ` +
+ + + +

Aucun appareil connecté

+
+ `; + return; + } + + devices.forEach(device => { + const deviceCard = document.createElement('div'); + deviceCard.className = 'device-card'; + + deviceCard.innerHTML = ` + ${getDeviceIcon(device.type)} +
${escapeHtml(device.name)}
+
${device.type}
+
+
${device.ip}
+
${device.mac}
+
+ `; + + devicesList.appendChild(deviceCard); + }); +} + +function addLogEntry(log) { + const logContainer = document.getElementById('logContainer'); + + // Remove placeholder if it exists + const placeholder = logContainer.querySelector('.log-placeholder'); + if (placeholder) { + placeholder.remove(); + } + + const logEntry = document.createElement('div'); + logEntry.className = 'log-entry'; + + const timestamp = new Date(log.timestamp).toLocaleTimeString('fr-FR'); + + logEntry.innerHTML = ` + ${timestamp} + [${escapeHtml(log.source)}] + ${escapeHtml(log.message)} + `; + + logContainer.appendChild(logEntry); + + if (appState.autoScrollLogs) { + logContainer.scrollTop = logContainer.scrollHeight; + } +} + +// ===== WebSocket Functions ===== + +function connectWebSocket() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/ws/logs`; + + try { + appState.ws = new WebSocket(wsUrl); + + appState.ws.onopen = function() { + console.log('WebSocket connected'); + appState.reconnectAttempts = 0; + }; + + appState.ws.onmessage = function(event) { + try { + const log = JSON.parse(event.data); + addLogEntry(log); + } catch (error) { + console.error('Error parsing WebSocket message:', error); + } + }; + + appState.ws.onerror = function(error) { + console.error('WebSocket error:', error); + }; + + appState.ws.onclose = function() { + console.log('WebSocket disconnected'); + + // Attempt to reconnect + if (appState.reconnectAttempts < appState.maxReconnectAttempts) { + appState.reconnectAttempts++; + setTimeout(connectWebSocket, 5000); + } + }; + } catch (error) { + console.error('Error creating WebSocket:', error); + } +} + +// ===== Utility Functions ===== + +function generateSignalBars(strength) { + const bars = []; + for (let i = 1; i <= 4; i++) { + const active = i <= strength ? 'active' : ''; + bars.push(`
`); + } + return `
${bars.join('')}
`; +} + +function getDeviceIcon(type) { + const icons = { + mobile: ``, + laptop: ``, + tablet: ``, + desktop: ``, + unknown: `` + }; + return icons[type] || icons.unknown; +} + +function formatUptime(seconds) { + const hours = Math.floor(seconds / 3600); + const minutes = Math.floor((seconds % 3600) / 60); + const secs = seconds % 60; + + return `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`; +} + +function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; +} + +function showToast(type, title, message) { + const toast = document.getElementById('toast'); + const toastIcon = document.getElementById('toastIcon'); + const toastTitle = document.getElementById('toastTitle'); + const toastMessage = document.getElementById('toastMessage'); + + const icons = { + success: '', + error: '', + warning: '', + info: '' + }; + + toast.className = `toast ${type}`; + toastIcon.innerHTML = icons[type]; + toastTitle.textContent = title; + toastMessage.textContent = message; + + setTimeout(() => toast.classList.add('show'), 10); + + setTimeout(() => toast.classList.remove('show'), 5000); +} + +function hideToast() { + document.getElementById('toast').classList.remove('show'); +} + +function showLoading(show) { + const overlay = document.getElementById('loadingOverlay'); + if (show) { + overlay.classList.add('show'); + } else { + overlay.classList.remove('show'); + } +} + +function toggleAutoScroll() { + appState.autoScrollLogs = !appState.autoScrollLogs; + const btn = document.getElementById('autoScrollBtn'); + + if (appState.autoScrollLogs) { + btn.style.opacity = '1'; + showToast('info', 'Auto-scroll', 'Auto-scroll activé'); + } else { + btn.style.opacity = '0.5'; + showToast('info', 'Auto-scroll', 'Auto-scroll désactivé'); + } +} + +// ===== Periodic Updates ===== + +function startPeriodicUpdates() { + // Update status every 5 seconds + setInterval(() => { + loadStatus(); + }, 5000); + + // Update devices every 10 seconds + setInterval(() => { + loadDevices(); + }, 10000); +} diff --git a/cmd/repeater/static/index.html b/cmd/repeater/static/index.html new file mode 100644 index 0000000..90e4be4 --- /dev/null +++ b/cmd/repeater/static/index.html @@ -0,0 +1,273 @@ + + + + + + Travel Router Control Panel + + + +
+
+
+

+ + + + Travel Router Control +

+
+
+ + Déconnecté +
+
+ + Hotspot actif +
+
+
+
+ +
+
+
+ + + +
+
+
0
+
Appareils connectés
+
+
+
+
+ + + +
+
+
0 MB
+
Données utilisées
+
+
+
+
+ + + +
+
+
00:00:00
+
Temps de fonctionnement
+
+
+
+
+ + + +
+
+
-
+
Réseau actuel
+
+
+
+ +
+
+
+

+ + + + Connexion WiFi Upstream +

+ +
+ +
+ +
+
+ Chargement des réseaux... +
+
+
+ +
+ + +
+ +
+ + +
+
+ +
+
+

+ + + + Configuration Hotspot +

+
+ + +
+
+ +
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+
+ +
+
+
+

+ + + + Appareils connectés +

+ 0 +
+ +
+
+ + + +

Aucun appareil connecté

+
+
+
+ +
+
+

+ + + + Logs système +

+
+ + +
+
+ +
+
+ + + +

Aucun log disponible

+
+
+
+
+
+ + +
+
+
+
+
+
+ +
+ + +
+
+

Chargement...

+
+ + + + diff --git a/cmd/repeater/static/style.css b/cmd/repeater/static/style.css new file mode 100644 index 0000000..2dc82b0 --- /dev/null +++ b/cmd/repeater/static/style.css @@ -0,0 +1,788 @@ +/* CSS Variables for theming */ +:root { + --primary-gradient: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + --secondary-gradient: linear-gradient(135deg, #f093fb 0%, #f5576c 100%); + --success-color: #10b981; + --danger-color: #ef4444; + --warning-color: #f59e0b; + --info-color: #3b82f6; + --background: #f3f4f6; + --card-background: #ffffff; + --text-primary: #1f2937; + --text-secondary: #6b7280; + --border-color: #e5e7eb; + --shadow-sm: 0 1px 2px 0 rgba(0, 0, 0, 0.05); + --shadow-md: 0 4px 6px -1px rgba(0, 0, 0, 0.1); + --shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.1); + --shadow-xl: 0 20px 25px -5px rgba(0, 0, 0, 0.1); + --radius-sm: 0.375rem; + --radius-md: 0.5rem; + --radius-lg: 0.75rem; + --radius-xl: 1rem; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif; + background: var(--primary-gradient); + min-height: 100vh; + padding: 1.5rem; + color: var(--text-primary); +} + +.container { + max-width: 1400px; + margin: 0 auto; + background: rgba(255, 255, 255, 0.98); + backdrop-filter: blur(20px); + border-radius: var(--radius-xl); + padding: 2rem; + box-shadow: var(--shadow-xl); +} + +/* Header Styles */ +.header { + margin-bottom: 2rem; + padding-bottom: 1.5rem; + border-bottom: 2px solid var(--border-color); +} + +.header-content { + display: flex; + justify-content: space-between; + align-items: center; + flex-wrap: wrap; + gap: 1rem; +} + +.header h1 { + color: var(--text-primary); + font-size: 2rem; + font-weight: 700; + display: flex; + align-items: center; + gap: 0.75rem; +} + +.logo-icon { + color: #667eea; +} + +.connection-info { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.status-badge { + display: inline-flex; + align-items: center; + gap: 0.5rem; + padding: 0.5rem 1rem; + border-radius: 9999px; + font-size: 0.875rem; + font-weight: 500; + background: var(--background); + border: 1px solid var(--border-color); +} + +.status-dot { + width: 0.625rem; + height: 0.625rem; + border-radius: 50%; + animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; +} + +.status-dot.active { + background: var(--success-color); +} + +.status-dot.offline { + background: var(--text-secondary); + animation: none; +} + +@keyframes pulse { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* Stats Grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); + gap: 1.5rem; + margin-bottom: 2rem; +} + +.stat-card { + background: var(--card-background); + border-radius: var(--radius-lg); + padding: 1.5rem; + box-shadow: var(--shadow-md); + border: 1px solid var(--border-color); + display: flex; + gap: 1rem; + align-items: center; + transition: all 0.3s ease; +} + +.stat-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-lg); +} + +.stat-icon { + width: 3rem; + height: 3rem; + border-radius: var(--radius-md); + background: var(--primary-gradient); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.stat-icon svg { + color: white; +} + +.stat-content { + flex: 1; +} + +.stat-value { + font-size: 1.875rem; + font-weight: 700; + color: var(--text-primary); + line-height: 1; + margin-bottom: 0.25rem; +} + +.stat-label { + font-size: 0.875rem; + color: var(--text-secondary); + font-weight: 500; +} + +/* Grid Layout */ +.grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(450px, 1fr)); + gap: 1.5rem; + margin-bottom: 1.5rem; +} + +/* Card Styles */ +.card { + background: var(--card-background); + border-radius: var(--radius-lg); + padding: 1.5rem; + box-shadow: var(--shadow-md); + border: 1px solid var(--border-color); + transition: all 0.3s ease; +} + +.card:hover { + box-shadow: var(--shadow-lg); +} + +.card-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 1.5rem; + padding-bottom: 1rem; + border-bottom: 1px solid var(--border-color); +} + +.card-header h2 { + color: var(--text-primary); + font-size: 1.25rem; + font-weight: 600; + display: flex; + align-items: center; + gap: 0.5rem; + margin: 0; +} + +.icon { + color: #667eea; +} + +/* Form Elements */ +.form-group { + margin-bottom: 1.25rem; +} + +.form-group label { + display: flex; + align-items: center; + gap: 0.5rem; + margin-bottom: 0.5rem; + color: var(--text-primary); + font-weight: 500; + font-size: 0.875rem; +} + +.input-icon { + color: var(--text-secondary); +} + +.form-group input, +.form-group select { + width: 100%; + padding: 0.75rem 1rem; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + font-size: 0.9375rem; + transition: all 0.2s ease; + background: var(--card-background); + color: var(--text-primary); +} + +.form-group input:focus, +.form-group select:focus { + outline: none; + border-color: #667eea; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +/* Buttons */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + border: none; + border-radius: var(--radius-md); + font-size: 0.9375rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + width: 100%; + color: white; +} + +.btn svg { + flex-shrink: 0; +} + +.btn-primary { + background: var(--primary-gradient); +} + +.btn-primary:hover { + transform: translateY(-1px); + box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3); +} + +.btn-secondary { + background: var(--text-secondary); +} + +.btn-secondary:hover { + background: #4b5563; + transform: translateY(-1px); +} + +.btn-danger { + background: var(--danger-color); +} + +.btn-danger:hover { + background: #dc2626; + transform: translateY(-1px); +} + +.btn:active { + transform: translateY(0); +} + +.btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +.btn-icon { + background: transparent; + border: none; + padding: 0.5rem; + border-radius: var(--radius-sm); + cursor: pointer; + color: var(--text-secondary); + transition: all 0.2s ease; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.btn-icon:hover { + background: var(--background); + color: var(--text-primary); +} + +.button-group { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 0.75rem; +} + +/* Toggle Switch */ +.toggle-switch { + position: relative; + display: inline-block; + width: 50px; + height: 26px; +} + +.toggle-switch input { + opacity: 0; + width: 0; + height: 0; +} + +.toggle-switch label { + position: absolute; + cursor: pointer; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: var(--text-secondary); + transition: 0.3s; + border-radius: 34px; +} + +.toggle-switch label:before { + position: absolute; + content: ""; + height: 20px; + width: 20px; + left: 3px; + bottom: 3px; + background-color: white; + transition: 0.3s; + border-radius: 50%; +} + +.toggle-switch input:checked + label { + background: var(--success-color); +} + +.toggle-switch input:checked + label:before { + transform: translateX(24px); +} + +/* WiFi List */ +.wifi-list { + max-height: 320px; + overflow-y: auto; + border: 1px solid var(--border-color); + border-radius: var(--radius-md); + background: var(--background); +} + +.wifi-item { + padding: 1rem; + border-bottom: 1px solid var(--border-color); + cursor: pointer; + transition: all 0.2s ease; + display: flex; + justify-content: space-between; + align-items: center; + background: var(--card-background); +} + +.wifi-item:hover { + background: var(--background); +} + +.wifi-item:last-child { + border-bottom: none; +} + +.wifi-item.selected { + background: #eff6ff; + border-left: 4px solid #667eea; +} + +.wifi-item.loading { + justify-content: center; + color: var(--text-secondary); + cursor: default; +} + +.wifi-info { + flex: 1; +} + +.wifi-ssid { + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.25rem; +} + +.wifi-details { + font-size: 0.8125rem; + color: var(--text-secondary); + display: flex; + gap: 1rem; +} + +.wifi-signal { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.signal-bars { + display: flex; + gap: 2px; + align-items: flex-end; + height: 16px; +} + +.signal-bar { + width: 3px; + background: var(--border-color); + border-radius: 2px; +} + +.signal-bar:nth-child(1) { height: 25%; } +.signal-bar:nth-child(2) { height: 50%; } +.signal-bar:nth-child(3) { height: 75%; } +.signal-bar:nth-child(4) { height: 100%; } + +.signal-bar.active { + background: var(--success-color); +} + +/* Devices */ +.devices-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); + gap: 1rem; + min-height: 200px; +} + +.device-card { + background: var(--background); + border-radius: var(--radius-md); + padding: 1.25rem; + text-align: center; + border: 1px solid var(--border-color); + transition: all 0.2s ease; +} + +.device-card:hover { + transform: translateY(-2px); + box-shadow: var(--shadow-md); +} + +.device-icon { + width: 48px; + height: 48px; + margin: 0 auto 0.75rem; + color: #667eea; +} + +.device-name { + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.25rem; + font-size: 0.9375rem; +} + +.device-type { + font-size: 0.75rem; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; + margin-bottom: 0.5rem; +} + +.device-info { + font-size: 0.8125rem; + color: var(--text-secondary); +} + +.device-placeholder { + grid-column: 1 / -1; + text-align: center; + padding: 3rem 1rem; + color: var(--text-secondary); +} + +.device-placeholder svg { + margin-bottom: 1rem; +} + +.device-count { + background: var(--primary-gradient); + color: white; + padding: 0.25rem 0.75rem; + border-radius: 9999px; + font-size: 0.875rem; + font-weight: 600; +} + +/* Logs */ +.log-container { + background: #1f2937; + color: #10b981; + padding: 1rem; + border-radius: var(--radius-md); + font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Mono', 'Consolas', 'Courier New', monospace; + font-size: 0.8125rem; + max-height: 400px; + overflow-y: auto; + line-height: 1.6; + min-height: 200px; +} + +.log-entry { + margin-bottom: 0.5rem; + display: flex; + gap: 0.75rem; +} + +.log-timestamp { + color: #6b7280; + flex-shrink: 0; +} + +.log-source { + color: #3b82f6; + font-weight: 600; + flex-shrink: 0; + min-width: 80px; +} + +.log-message { + color: #d1d5db; +} + +.log-controls { + display: flex; + gap: 0.5rem; +} + +.log-placeholder { + text-align: center; + padding: 3rem 1rem; + color: #6b7280; +} + +.log-placeholder svg { + margin-bottom: 1rem; +} + +/* Toast Notifications */ +.toast { + position: fixed; + top: 1.5rem; + right: 1.5rem; + max-width: 400px; + background: white; + border-radius: var(--radius-lg); + box-shadow: var(--shadow-xl); + padding: 1rem; + display: flex; + gap: 0.75rem; + align-items: start; + transform: translateX(calc(100% + 2rem)); + transition: transform 0.3s ease; + z-index: 1000; + border: 1px solid var(--border-color); +} + +.toast.show { + transform: translateX(0); +} + +.toast-icon { + width: 40px; + height: 40px; + border-radius: var(--radius-md); + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.toast.success .toast-icon { + background: #d1fae5; + color: var(--success-color); +} + +.toast.error .toast-icon { + background: #fee2e2; + color: var(--danger-color); +} + +.toast.warning .toast-icon { + background: #fef3c7; + color: var(--warning-color); +} + +.toast.info .toast-icon { + background: #dbeafe; + color: var(--info-color); +} + +.toast-content { + flex: 1; +} + +.toast-title { + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.25rem; +} + +.toast-message { + font-size: 0.875rem; + color: var(--text-secondary); +} + +.toast-close { + background: transparent; + border: none; + color: var(--text-secondary); + cursor: pointer; + padding: 0.25rem; + border-radius: var(--radius-sm); + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.toast-close:hover { + background: var(--background); + color: var(--text-primary); +} + +/* Loading Overlay */ +.loading-overlay { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.5); + backdrop-filter: blur(4px); + display: none; + align-items: center; + justify-content: center; + z-index: 2000; + flex-direction: column; + gap: 1rem; +} + +.loading-overlay.show { + display: flex; +} + +.loading-overlay p { + color: white; + font-weight: 500; +} + +.spinner { + width: 48px; + height: 48px; + border: 4px solid rgba(255, 255, 255, 0.3); + border-top-color: white; + border-radius: 50%; + animation: spin 0.8s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* Scrollbar Styling */ +.wifi-list::-webkit-scrollbar, +.log-container::-webkit-scrollbar { + width: 8px; +} + +.wifi-list::-webkit-scrollbar-track, +.log-container::-webkit-scrollbar-track { + background: transparent; +} + +.wifi-list::-webkit-scrollbar-thumb { + background: var(--border-color); + border-radius: 4px; +} + +.log-container::-webkit-scrollbar-thumb { + background: #374151; + border-radius: 4px; +} + +.wifi-list::-webkit-scrollbar-thumb:hover, +.log-container::-webkit-scrollbar-thumb:hover { + background: var(--text-secondary); +} + +/* Responsive Design */ +@media (max-width: 1024px) { + .grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 768px) { + body { + padding: 1rem; + } + + .container { + padding: 1.5rem; + } + + .header h1 { + font-size: 1.5rem; + } + + .header-content { + flex-direction: column; + align-items: flex-start; + } + + .stats-grid { + grid-template-columns: 1fr; + } + + .grid { + grid-template-columns: 1fr; + } + + .button-group { + grid-template-columns: 1fr; + } + + .toast { + left: 1rem; + right: 1rem; + max-width: none; + } +} + +@media (max-width: 480px) { + .stat-card { + flex-direction: column; + text-align: center; + } + + .devices-grid { + grid-template-columns: 1fr; + } +} diff --git a/go.mod b/go.mod index c90ffaa..5e17b30 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module git.nemunai.re/nemunaire/repeater +module github.com/nemunaire/repeater go 1.24.4 diff --git a/internal/api/handlers/handlers.go b/internal/api/handlers/handlers.go new file mode 100644 index 0000000..49d6c3a --- /dev/null +++ b/internal/api/handlers/handlers.go @@ -0,0 +1,132 @@ +package handlers + +import ( + "net/http" + + "github.com/gin-gonic/gin" + "github.com/nemunaire/repeater/internal/device" + "github.com/nemunaire/repeater/internal/hotspot" + "github.com/nemunaire/repeater/internal/logging" + "github.com/nemunaire/repeater/internal/models" + "github.com/nemunaire/repeater/internal/wifi" +) + +// ScanWiFi handles WiFi network scanning +func ScanWiFi(c *gin.Context) { + networks, err := wifi.ScanNetworks() + if err != nil { + logging.AddLog("WiFi", "Erreur lors du scan: "+err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors du scan WiFi"}) + return + } + + logging.AddLog("WiFi", "Scan terminé - "+string(rune(len(networks)))+" réseaux trouvés") + c.JSON(http.StatusOK, networks) +} + +// ConnectWiFi handles WiFi connection requests +func ConnectWiFi(c *gin.Context) { + var req models.WiFiConnectRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"}) + return + } + + logging.AddLog("WiFi", "Tentative de connexion à "+req.SSID) + + err := wifi.Connect(req.SSID, req.Password) + if err != nil { + logging.AddLog("WiFi", "Échec de connexion: "+err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Échec de connexion: " + err.Error()}) + return + } + + logging.AddLog("WiFi", "Connexion réussie à "+req.SSID) + c.JSON(http.StatusOK, gin.H{"status": "success"}) +} + +// DisconnectWiFi handles WiFi disconnection +func DisconnectWiFi(c *gin.Context) { + logging.AddLog("WiFi", "Tentative de déconnexion") + + err := wifi.Disconnect() + if err != nil { + logging.AddLog("WiFi", "Erreur de déconnexion: "+err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur de déconnexion: " + err.Error()}) + return + } + + logging.AddLog("WiFi", "Déconnexion réussie") + c.JSON(http.StatusOK, gin.H{"status": "success"}) +} + +// ConfigureHotspot handles hotspot configuration +func ConfigureHotspot(c *gin.Context) { + var config models.HotspotConfig + if err := c.ShouldBindJSON(&config); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Données invalides"}) + return + } + + err := hotspot.Configure(config) + if err != nil { + logging.AddLog("Hotspot", "Erreur de configuration: "+err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur de configuration: " + err.Error()}) + return + } + + logging.AddLog("Hotspot", "Configuration mise à jour: "+config.SSID) + c.JSON(http.StatusOK, gin.H{"status": "success"}) +} + +// ToggleHotspot handles hotspot enable/disable +func ToggleHotspot(c *gin.Context, status *models.SystemStatus) { + status.HotspotEnabled = !status.HotspotEnabled + enabled := status.HotspotEnabled + + var err error + if enabled { + err = hotspot.Start() + logging.AddLog("Hotspot", "Hotspot activé") + } else { + err = hotspot.Stop() + logging.AddLog("Hotspot", "Hotspot désactivé") + } + + if err != nil { + logging.AddLog("Hotspot", "Erreur: "+err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur: " + err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"enabled": enabled}) +} + +// GetDevices returns connected devices +func GetDevices(c *gin.Context) { + devices, err := device.GetConnectedDevices() + if err != nil { + logging.AddLog("Système", "Erreur lors de la récupération des appareils: "+err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la récupération des appareils"}) + return + } + + c.JSON(http.StatusOK, devices) +} + +// GetStatus returns system status +func GetStatus(c *gin.Context, status *models.SystemStatus) { + c.JSON(http.StatusOK, status) +} + +// GetLogs returns system logs +func GetLogs(c *gin.Context) { + logs := logging.GetLogs() + c.JSON(http.StatusOK, logs) +} + +// ClearLogs clears system logs +func ClearLogs(c *gin.Context) { + logging.ClearLogs() + c.JSON(http.StatusOK, gin.H{"status": "success"}) +} diff --git a/internal/api/handlers/websocket.go b/internal/api/handlers/websocket.go new file mode 100644 index 0000000..87d2823 --- /dev/null +++ b/internal/api/handlers/websocket.go @@ -0,0 +1,38 @@ +package handlers + +import ( + "log" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/gorilla/websocket" + "github.com/nemunaire/repeater/internal/logging" +) + +var upgrader = websocket.Upgrader{ + CheckOrigin: func(r *http.Request) bool { + return true + }, +} + +// WebSocketLogs handles WebSocket connections for real-time logs +func WebSocketLogs(c *gin.Context) { + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + log.Printf("Erreur WebSocket: %v", err) + return + } + defer conn.Close() + + // Register client + logging.RegisterWebSocketClient(conn) + defer logging.UnregisterWebSocketClient(conn) + + // Keep connection alive + for { + _, _, err := conn.ReadMessage() + if err != nil { + break + } + } +} diff --git a/internal/api/router.go b/internal/api/router.go new file mode 100644 index 0000000..5558494 --- /dev/null +++ b/internal/api/router.go @@ -0,0 +1,64 @@ +package api + +import ( + "embed" + "io/fs" + "net/http" + + "github.com/gin-gonic/gin" + "github.com/nemunaire/repeater/internal/api/handlers" + "github.com/nemunaire/repeater/internal/models" +) + +// SetupRouter creates and configures the Gin router +func SetupRouter(status *models.SystemStatus, assets embed.FS) *gin.Engine { + // Set Gin to release mode (can be overridden with GIN_MODE env var) + gin.SetMode(gin.ReleaseMode) + + r := gin.Default() + + // API routes + api := r.Group("/api") + { + // WiFi endpoints + wifi := api.Group("/wifi") + { + wifi.GET("/scan", handlers.ScanWiFi) + wifi.POST("/connect", handlers.ConnectWiFi) + wifi.POST("/disconnect", handlers.DisconnectWiFi) + } + + // Hotspot endpoints + hotspot := api.Group("/hotspot") + { + hotspot.POST("/config", handlers.ConfigureHotspot) + hotspot.POST("/toggle", func(c *gin.Context) { + handlers.ToggleHotspot(c, status) + }) + } + + // Device endpoints + api.GET("/devices", handlers.GetDevices) + + // Status endpoint + api.GET("/status", func(c *gin.Context) { + handlers.GetStatus(c, status) + }) + + // Log endpoints + api.GET("/logs", handlers.GetLogs) + api.DELETE("/logs", handlers.ClearLogs) + } + + // WebSocket endpoint + r.GET("/ws/logs", handlers.WebSocketLogs) + + // Serve static files + sub, err := fs.Sub(assets, "static") + if err != nil { + panic("Unable to access static directory: " + err.Error()) + } + r.NoRoute(gin.WrapH(http.FileServer(http.FS(sub)))) + + return r +} diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..39181e6 --- /dev/null +++ b/internal/app/app.go @@ -0,0 +1,102 @@ +package app + +import ( + "embed" + "log" + "sync" + "time" + + "github.com/nemunaire/repeater/internal/api" + "github.com/nemunaire/repeater/internal/device" + "github.com/nemunaire/repeater/internal/logging" + "github.com/nemunaire/repeater/internal/models" + "github.com/nemunaire/repeater/internal/wifi" +) + +// App represents the application +type App struct { + Status models.SystemStatus + StatusMutex sync.RWMutex + StartTime time.Time + Assets embed.FS +} + +// New creates a new application instance +func New(assets embed.FS) *App { + return &App{ + Status: models.SystemStatus{ + Connected: false, + ConnectedSSID: "", + HotspotEnabled: true, + ConnectedCount: 0, + DataUsage: 0.0, + Uptime: 0, + }, + StartTime: time.Now(), + Assets: assets, + } +} + +// Initialize initializes the application +func (a *App) Initialize() error { + // Initialize WiFi D-Bus connection + if err := wifi.Initialize(); err != nil { + return err + } + + // Start periodic tasks + go a.periodicStatusUpdate() + go a.periodicDeviceUpdate() + + logging.AddLog("Système", "Application initialisée") + return nil +} + +// Run starts the HTTP server +func (a *App) Run(addr string) error { + router := api.SetupRouter(&a.Status, a.Assets) + + logging.AddLog("Système", "Serveur API démarré sur "+addr) + return router.Run(addr) +} + +// Shutdown gracefully shuts down the application +func (a *App) Shutdown() { + wifi.Close() + logging.AddLog("Système", "Application arrêtée") +} + +// periodicStatusUpdate updates WiFi connection status periodically +func (a *App) periodicStatusUpdate() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + + for range ticker.C { + a.StatusMutex.Lock() + a.Status.Connected = wifi.IsConnected() + if !a.Status.Connected { + a.Status.ConnectedSSID = "" + } + a.Status.Uptime = int64(time.Since(a.StartTime).Seconds()) + a.StatusMutex.Unlock() + } +} + +// periodicDeviceUpdate updates connected devices list periodically +func (a *App) periodicDeviceUpdate() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for range ticker.C { + devices, err := device.GetConnectedDevices() + if err != nil { + log.Printf("Error getting connected devices: %v", err) + continue + } + + a.StatusMutex.Lock() + a.Status.ConnectedDevices = devices + a.Status.ConnectedCount = len(devices) + a.StatusMutex.Unlock() + } +} diff --git a/internal/device/device.go b/internal/device/device.go new file mode 100644 index 0000000..5eaaf06 --- /dev/null +++ b/internal/device/device.go @@ -0,0 +1,132 @@ +package device + +import ( + "bufio" + "os" + "os/exec" + "regexp" + "strings" + + "github.com/nemunaire/repeater/internal/models" +) + +// GetConnectedDevices returns a list of connected devices +func GetConnectedDevices() ([]models.ConnectedDevice, error) { + var devices []models.ConnectedDevice + + // Read DHCP leases + leases, err := parseDHCPLeases() + if err != nil { + return devices, err + } + + // Get ARP information + arpInfo, err := getARPInfo() + if err != nil { + return devices, err + } + + for _, lease := range leases { + device := models.ConnectedDevice{ + Name: lease.Hostname, + MAC: lease.MAC, + IP: lease.IP, + Type: guessDeviceType(lease.Hostname, lease.MAC), + } + + // Check if the device is still connected via ARP + if _, exists := arpInfo[lease.IP]; exists { + devices = append(devices, device) + } + } + + return devices, nil +} + +// parseDHCPLeases reads and parses DHCP lease file +func parseDHCPLeases() ([]models.DHCPLease, error) { + var leases []models.DHCPLease + + file, err := os.Open("/var/lib/dhcp/dhcpd.leases") + if err != nil { + return leases, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + var currentLease models.DHCPLease + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + if strings.HasPrefix(line, "lease ") { + ip := strings.Fields(line)[1] + currentLease = models.DHCPLease{IP: ip} + } else if strings.Contains(line, "hardware ethernet") { + mac := strings.Fields(line)[2] + mac = strings.TrimSuffix(mac, ";") + currentLease.MAC = mac + } else if strings.Contains(line, "client-hostname") { + hostname := strings.Fields(line)[1] + hostname = strings.Trim(hostname, `";`) + currentLease.Hostname = hostname + } else if line == "}" { + if currentLease.IP != "" && currentLease.MAC != "" { + leases = append(leases, currentLease) + } + currentLease = models.DHCPLease{} + } + } + + return leases, nil +} + +// getARPInfo retrieves ARP table information +func getARPInfo() (map[string]string, error) { + arpInfo := make(map[string]string) + + cmd := exec.Command("arp", "-a") + output, err := cmd.Output() + if err != nil { + return arpInfo, err + } + + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if matches := regexp.MustCompile(`\(([^)]+)\) at ([0-9a-fA-F:]{17})`).FindStringSubmatch(line); len(matches) > 2 { + ip := matches[1] + mac := matches[2] + arpInfo[ip] = mac + } + } + + return arpInfo, nil +} + +// guessDeviceType attempts to guess device type from hostname and MAC address +func guessDeviceType(hostname, mac string) string { + hostname = strings.ToLower(hostname) + + if strings.Contains(hostname, "iphone") || strings.Contains(hostname, "android") { + return "mobile" + } else if strings.Contains(hostname, "ipad") || strings.Contains(hostname, "tablet") { + return "tablet" + } else if strings.Contains(hostname, "macbook") || strings.Contains(hostname, "laptop") { + return "laptop" + } + + // Guess by MAC prefix (OUI) + if len(mac) >= 8 { + macPrefix := strings.ToUpper(mac[:8]) + switch macPrefix { + case "00:50:56", "00:0C:29", "00:05:69": // VMware + return "laptop" + case "08:00:27": // VirtualBox + return "laptop" + default: + return "mobile" + } + } + + return "unknown" +} diff --git a/internal/hotspot/hotspot.go b/internal/hotspot/hotspot.go new file mode 100644 index 0000000..aff0483 --- /dev/null +++ b/internal/hotspot/hotspot.go @@ -0,0 +1,47 @@ +package hotspot + +import ( + "fmt" + "os" + "os/exec" + + "github.com/nemunaire/repeater/internal/models" +) + +const ( + AP_INTERFACE = "wlan1" + HOSTAPD_CONF = "/etc/hostapd/hostapd.conf" +) + +// Configure updates the hotspot configuration +func Configure(config models.HotspotConfig) error { + hostapdConfig := fmt.Sprintf(`interface=%s +driver=nl80211 +ssid=%s +hw_mode=g +channel=%d +wmm_enabled=0 +macaddr_acl=0 +auth_algs=1 +ignore_broadcast_ssid=0 +wpa=2 +wpa_passphrase=%s +wpa_key_mgmt=WPA-PSK +wpa_pairwise=TKIP +rsn_pairwise=CCMP +`, AP_INTERFACE, config.SSID, config.Channel, config.Password) + + return os.WriteFile(HOSTAPD_CONF, []byte(hostapdConfig), 0644) +} + +// Start starts the hotspot +func Start() error { + cmd := exec.Command("systemctl", "start", "hostapd") + return cmd.Run() +} + +// Stop stops the hotspot +func Stop() error { + cmd := exec.Command("systemctl", "stop", "hostapd") + return cmd.Run() +} diff --git a/internal/logging/logging.go b/internal/logging/logging.go new file mode 100644 index 0000000..064a813 --- /dev/null +++ b/internal/logging/logging.go @@ -0,0 +1,94 @@ +package logging + +import ( + "log" + "sync" + "time" + + "github.com/gorilla/websocket" + "github.com/nemunaire/repeater/internal/models" +) + +var ( + logEntries []models.LogEntry + logMutex sync.RWMutex + websocketClients = make(map[*websocket.Conn]bool) + clientsMutex sync.RWMutex +) + +// AddLog adds a new log entry +func AddLog(source, message string) { + logMutex.Lock() + entry := models.LogEntry{ + Timestamp: time.Now(), + Source: source, + Message: message, + } + logEntries = append(logEntries, entry) + + // Keep only the last 100 logs + if len(logEntries) > 100 { + logEntries = logEntries[len(logEntries)-100:] + } + logMutex.Unlock() + + // Broadcast to WebSocket clients + broadcastToWebSockets(entry) + + // Log to console + log.Printf("[%s] %s", source, message) +} + +// GetLogs returns all log entries +func GetLogs() []models.LogEntry { + logMutex.RLock() + defer logMutex.RUnlock() + + logs := make([]models.LogEntry, len(logEntries)) + copy(logs, logEntries) + return logs +} + +// ClearLogs clears all log entries +func ClearLogs() { + logMutex.Lock() + logEntries = []models.LogEntry{} + logMutex.Unlock() + + AddLog("Système", "Logs effacés") +} + +// RegisterWebSocketClient registers a new WebSocket client +func RegisterWebSocketClient(conn *websocket.Conn) { + clientsMutex.Lock() + websocketClients[conn] = true + clientsMutex.Unlock() + + // Send existing logs to the new client + logMutex.RLock() + for _, entry := range logEntries { + conn.WriteJSON(entry) + } + logMutex.RUnlock() +} + +// UnregisterWebSocketClient removes a WebSocket client +func UnregisterWebSocketClient(conn *websocket.Conn) { + clientsMutex.Lock() + delete(websocketClients, conn) + clientsMutex.Unlock() +} + +// broadcastToWebSockets sends a log entry to all connected WebSocket clients +func broadcastToWebSockets(entry models.LogEntry) { + clientsMutex.RLock() + defer clientsMutex.RUnlock() + + for client := range websocketClients { + err := client.WriteJSON(entry) + if err != nil { + client.Close() + delete(websocketClients, client) + } + } +} diff --git a/internal/models/models.go b/internal/models/models.go new file mode 100644 index 0000000..d582364 --- /dev/null +++ b/internal/models/models.go @@ -0,0 +1,58 @@ +package models + +import "time" + +// WiFiNetwork represents a discovered WiFi network +type WiFiNetwork struct { + SSID string `json:"ssid"` + Signal int `json:"signal"` + Security string `json:"security"` + Channel int `json:"channel"` + BSSID string `json:"bssid"` +} + +// ConnectedDevice represents a device connected to the hotspot +type ConnectedDevice struct { + Name string `json:"name"` + Type string `json:"type"` + MAC string `json:"mac"` + IP string `json:"ip"` +} + +// HotspotConfig represents hotspot configuration +type HotspotConfig struct { + SSID string `json:"ssid"` + Password string `json:"password"` + Channel int `json:"channel"` +} + +// SystemStatus represents overall system status +type SystemStatus struct { + Connected bool `json:"connected"` + ConnectedSSID string `json:"connectedSSID"` + HotspotEnabled bool `json:"hotspotEnabled"` + ConnectedCount int `json:"connectedCount"` + DataUsage float64 `json:"dataUsage"` + Uptime int64 `json:"uptime"` + ConnectedDevices []ConnectedDevice `json:"connectedDevices"` +} + +// WiFiConnectRequest represents a request to connect to WiFi +type WiFiConnectRequest struct { + SSID string `json:"ssid" binding:"required"` + Password string `json:"password"` +} + +// LogEntry represents a system log entry +type LogEntry struct { + Timestamp time.Time `json:"timestamp"` + Source string `json:"source"` + Message string `json:"message"` +} + +// DHCPLease represents a DHCP lease entry +type DHCPLease struct { + IP string + MAC string + Hostname string +} diff --git a/internal/wifi/wifi.go b/internal/wifi/wifi.go new file mode 100644 index 0000000..e10731b --- /dev/null +++ b/internal/wifi/wifi.go @@ -0,0 +1,273 @@ +package wifi + +import ( + "fmt" + "os/exec" + "sort" + "strings" + "time" + + "github.com/godbus/dbus/v5" + "github.com/nemunaire/repeater/internal/models" +) + +const ( + WLAN_INTERFACE = "wlan0" + WPA_CONF = "/etc/wpa_supplicant/wpa_supplicant.conf" + + // D-Bus constants for wpa_supplicant + WPA_SUPPLICANT_SERVICE = "fi.w1.wpa_supplicant1" + WPA_SUPPLICANT_PATH = "/fi/w1/wpa_supplicant1" + WPA_SUPPLICANT_IFACE = "fi.w1.wpa_supplicant1" + WPA_INTERFACE_IFACE = "fi.w1.wpa_supplicant1.Interface" + WPA_BSS_IFACE = "fi.w1.wpa_supplicant1.BSS" + WPA_NETWORK_IFACE = "fi.w1.wpa_supplicant1.Network" +) + +var ( + dbusConn *dbus.Conn + wpaSupplicant dbus.BusObject +) + +// Initialize initializes the WiFi service with D-Bus connection +func Initialize() error { + var err error + dbusConn, err = dbus.SystemBus() + if err != nil { + return fmt.Errorf("failed to connect to D-Bus: %v", err) + } + + wpaSupplicant = dbusConn.Object(WPA_SUPPLICANT_SERVICE, dbus.ObjectPath(WPA_SUPPLICANT_PATH)) + return nil +} + +// Close closes the D-Bus connection +func Close() { + if dbusConn != nil { + dbusConn.Close() + } +} + +// ScanNetworks scans for available WiFi networks +func ScanNetworks() ([]models.WiFiNetwork, error) { + interfacePath, err := getWiFiInterfacePath() + if err != nil { + return nil, fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err) + } + + // Trigger a scan + wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath) + call := wifiInterface.Call(WPA_INTERFACE_IFACE+".Scan", 0, map[string]dbus.Variant{"Type": dbus.MakeVariant("active")}) + if call.Err != nil { + return nil, fmt.Errorf("erreur lors du scan: %v", call.Err) + } + + // Wait for scan to complete + time.Sleep(2 * time.Second) + + // Retrieve BSS list + bssePaths, err := wifiInterface.GetProperty(WPA_INTERFACE_IFACE + ".BSSs") + if err != nil { + return nil, fmt.Errorf("erreur lors de la récupération des BSS: %v", err) + } + + var networks []models.WiFiNetwork + seenSSIDs := make(map[string]bool) + + for _, bssPath := range bssePaths.Value().([]dbus.ObjectPath) { + bss := dbusConn.Object(WPA_SUPPLICANT_SERVICE, bssPath) + + // Get BSS properties + var props map[string]dbus.Variant + err = bss.Call("org.freedesktop.DBus.Properties.GetAll", 0, WPA_BSS_IFACE).Store(&props) + if err != nil { + continue + } + + network := models.WiFiNetwork{} + + // Extract SSID + if ssidBytes, ok := props["SSID"].Value().([]byte); ok { + network.SSID = string(ssidBytes) + } + + // Skip duplicates and empty SSIDs + if network.SSID == "" || seenSSIDs[network.SSID] { + continue + } + seenSSIDs[network.SSID] = true + + // Extract BSSID + if bssidBytes, ok := props["BSSID"].Value().([]byte); ok { + network.BSSID = fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", + bssidBytes[0], bssidBytes[1], bssidBytes[2], bssidBytes[3], bssidBytes[4], bssidBytes[5]) + } + + // Extract signal strength + if signal, ok := props["Signal"].Value().(int16); ok { + network.Signal = signalToStrength(int(signal)) + } + + // Extract frequency and calculate channel + if frequency, ok := props["Frequency"].Value().(uint16); ok { + network.Channel = frequencyToChannel(int(frequency)) + } + + // Determine security + if privacyVal, ok := props["Privacy"].Value().(bool); ok && privacyVal { + if wpaProps, ok := props["WPA"].Value().(map[string]dbus.Variant); ok && len(wpaProps) > 0 { + network.Security = "WPA" + } else if rsnProps, ok := props["RSN"].Value().(map[string]dbus.Variant); ok && len(rsnProps) > 0 { + network.Security = "WPA2" + } else { + network.Security = "WEP" + } + } else { + network.Security = "Open" + } + + networks = append(networks, network) + } + + // Sort by signal strength + sort.Slice(networks, func(i, j int) bool { + return networks[i].Signal > networks[j].Signal + }) + + return networks, nil +} + +// Connect connects to a WiFi network using D-Bus +func Connect(ssid, password string) error { + interfacePath, err := getWiFiInterfacePath() + if err != nil { + return fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err) + } + + wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath) + + // Create a new network + networkConfig := map[string]dbus.Variant{ + "ssid": dbus.MakeVariant(ssid), + } + + if password != "" { + networkConfig["psk"] = dbus.MakeVariant(password) + } + + var networkPath dbus.ObjectPath + err = wifiInterface.Call(WPA_INTERFACE_IFACE+".AddNetwork", 0, networkConfig).Store(&networkPath) + if err != nil { + return fmt.Errorf("erreur lors de l'ajout du réseau: %v", err) + } + + // Select the network + err = wifiInterface.Call(WPA_INTERFACE_IFACE+".SelectNetwork", 0, networkPath).Err + if err != nil { + return fmt.Errorf("erreur lors de la sélection du réseau: %v", err) + } + + // Wait for connection + for i := 0; i < 20; i++ { + time.Sleep(500 * time.Millisecond) + if IsConnected() { + return nil + } + } + + return fmt.Errorf("timeout lors de la connexion") +} + +// Disconnect disconnects from the current WiFi network +func Disconnect() error { + interfacePath, err := getWiFiInterfacePath() + if err != nil { + return fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err) + } + + wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath) + + // Disconnect + err = wifiInterface.Call(WPA_INTERFACE_IFACE+".Disconnect", 0).Err + if err != nil { + return fmt.Errorf("erreur lors de la déconnexion: %v", err) + } + + // Remove all networks + var networks []dbus.ObjectPath + err = wifiInterface.Call(WPA_INTERFACE_IFACE+".Get", 0, WPA_INTERFACE_IFACE, "Networks").Store(&networks) + if err == nil { + for _, networkPath := range networks { + wifiInterface.Call(WPA_INTERFACE_IFACE+".RemoveNetwork", 0, networkPath) + } + } + + return nil +} + +// IsConnected checks if WiFi is connected using D-Bus +func IsConnected() bool { + interfacePath, err := getWiFiInterfacePath() + if err != nil { + return false + } + + wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath) + var state string + err = wifiInterface.Call(WPA_INTERFACE_IFACE+".Get", 0, WPA_INTERFACE_IFACE, "State").Store(&state) + if err != nil { + return false + } + + return state == "completed" +} + +// IsConnectedLegacy checks if WiFi is connected using iwconfig (fallback) +func IsConnectedLegacy() bool { + cmd := exec.Command("iwconfig", WLAN_INTERFACE) + output, err := cmd.Output() + if err != nil { + return false + } + + return strings.Contains(string(output), "Access Point:") +} + +// getWiFiInterfacePath retrieves the D-Bus path for the WiFi interface +func getWiFiInterfacePath() (dbus.ObjectPath, error) { + var interfacePath dbus.ObjectPath + err := wpaSupplicant.Call(WPA_SUPPLICANT_IFACE+".GetInterface", 0, WLAN_INTERFACE).Store(&interfacePath) + if err != nil { + return "", fmt.Errorf("erreur lors de la récupération des interfaces: %v", err) + } + + return interfacePath, nil +} + +// frequencyToChannel converts WiFi frequency to channel number +func frequencyToChannel(frequency int) int { + if frequency >= 2412 && frequency <= 2484 { + if frequency == 2484 { + return 14 + } + return (frequency-2412)/5 + 1 + } else if frequency >= 5170 && frequency <= 5825 { + return (frequency - 5000) / 5 + } + return 0 +} + +// signalToStrength converts signal level (dBm) to strength (1-5) +func signalToStrength(level int) int { + if level >= -30 { + return 5 + } else if level >= -50 { + return 4 + } else if level >= -60 { + return 3 + } else if level >= -70 { + return 2 + } else { + return 1 + } +} diff --git a/main.go b/main.go deleted file mode 100644 index c8b9fe7..0000000 --- a/main.go +++ /dev/null @@ -1,826 +0,0 @@ -package main - -import ( - "bufio" - "embed" - "encoding/json" - "fmt" - "io/fs" - "log" - "net/http" - "os" - "os/exec" - "regexp" - "sort" - "strings" - "sync" - "time" - - "github.com/godbus/dbus/v5" - "github.com/gorilla/mux" - "github.com/gorilla/websocket" -) - -//go:embed all:static -var _assets embed.FS - -// Structures de données -type WiFiNetwork struct { - SSID string `json:"ssid"` - Signal int `json:"signal"` - Security string `json:"security"` - Channel int `json:"channel"` - BSSID string `json:"bssid"` -} - -type ConnectedDevice struct { - Name string `json:"name"` - Type string `json:"type"` - MAC string `json:"mac"` - IP string `json:"ip"` -} - -type HotspotConfig struct { - SSID string `json:"ssid"` - Password string `json:"password"` - Channel int `json:"channel"` -} - -type SystemStatus struct { - Connected bool `json:"connected"` - ConnectedSSID string `json:"connectedSSID"` - HotspotEnabled bool `json:"hotspotEnabled"` - ConnectedCount int `json:"connectedCount"` - DataUsage float64 `json:"dataUsage"` - Uptime int64 `json:"uptime"` - ConnectedDevices []ConnectedDevice `json:"connectedDevices"` -} - -type WiFiConnectRequest struct { - SSID string `json:"ssid"` - Password string `json:"password"` -} - -type LogEntry struct { - Timestamp time.Time `json:"timestamp"` - Source string `json:"source"` - Message string `json:"message"` -} - -// Variables globales -var ( - currentStatus SystemStatus - statusMutex sync.RWMutex - logEntries []LogEntry - logMutex sync.RWMutex - websocketClients = make(map[*websocket.Conn]bool) - clientsMutex sync.RWMutex - upgrader = websocket.Upgrader{ - CheckOrigin: func(r *http.Request) bool { - return true - }, - } - startTime = time.Now() - dbusConn *dbus.Conn - wpaSupplicant dbus.BusObject -) - -const ( - WLAN_INTERFACE = "wlan0" - AP_INTERFACE = "wlan1" - HOSTAPD_CONF = "/etc/hostapd/hostapd.conf" - WPA_CONF = "/etc/wpa_supplicant/wpa_supplicant.conf" - - // D-Bus constantes pour wpa_supplicant - WPA_SUPPLICANT_SERVICE = "fi.w1.wpa_supplicant1" - WPA_SUPPLICANT_PATH = "/fi/w1/wpa_supplicant1" - WPA_SUPPLICANT_IFACE = "fi.w1.wpa_supplicant1" - WPA_INTERFACE_IFACE = "fi.w1.wpa_supplicant1.Interface" - WPA_BSS_IFACE = "fi.w1.wpa_supplicant1.BSS" - WPA_NETWORK_IFACE = "fi.w1.wpa_supplicant1.Network" -) - -func main() { - // Initialiser D-Bus - var err error - dbusConn, err = dbus.SystemBus() - if err != nil { - log.Fatalf("Erreur de connexion D-Bus: %v", err) - } - defer dbusConn.Close() - - wpaSupplicant = dbusConn.Object(WPA_SUPPLICANT_SERVICE, dbus.ObjectPath(WPA_SUPPLICANT_PATH)) - - // Initialiser le statut système - initializeStatus() - - // Démarrer les tâches périodiques - go periodicStatusUpdate() - go periodicDeviceUpdate() - - // Configuration du routeur - r := mux.NewRouter() - - // Routes API - api := r.PathPrefix("/api").Subrouter() - api.HandleFunc("/wifi/scan", scanWiFiHandler).Methods("GET") - api.HandleFunc("/wifi/connect", connectWiFiHandler).Methods("POST") - api.HandleFunc("/wifi/disconnect", disconnectWiFiHandler).Methods("POST") - api.HandleFunc("/hotspot/config", configureHotspotHandler).Methods("POST") - api.HandleFunc("/hotspot/toggle", toggleHotspotHandler).Methods("POST") - api.HandleFunc("/devices", getDevicesHandler).Methods("GET") - api.HandleFunc("/status", getStatusHandler).Methods("GET") - api.HandleFunc("/logs", getLogsHandler).Methods("GET") - api.HandleFunc("/logs", clearLogsHandler).Methods("DELETE") - - // WebSocket pour les logs en temps réel - r.HandleFunc("/ws/logs", websocketHandler) - - // Servir les fichiers statiques - sub, err := fs.Sub(_assets, "static") - if err != nil { - log.Fatal("Unable to cd to static/ directory:", err) - } - Assets := http.FS(sub) - r.PathPrefix("/").Handler(http.FileServer(Assets)) - - addLog("Système", "Serveur API démarré sur le port 8080") - log.Fatal(http.ListenAndServe(":8080", r)) -} - -func initializeStatus() { - statusMutex.Lock() - defer statusMutex.Unlock() - - currentStatus = SystemStatus{ - Connected: false, - ConnectedSSID: "", - HotspotEnabled: true, - ConnectedCount: 0, - DataUsage: 0.0, - Uptime: 0, - } -} - -// Handlers API - -func scanWiFiHandler(w http.ResponseWriter, r *http.Request) { - networks, err := scanWiFiNetworks() - if err != nil { - addLog("WiFi", fmt.Sprintf("Erreur lors du scan: %v", err)) - http.Error(w, "Erreur lors du scan WiFi", http.StatusInternalServerError) - return - } - - addLog("WiFi", fmt.Sprintf("Scan terminé - %d réseaux trouvés", len(networks))) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(networks) -} - -func connectWiFiHandler(w http.ResponseWriter, r *http.Request) { - var req WiFiConnectRequest - if err := json.NewDecoder(r.Body).Decode(&req); err != nil { - http.Error(w, "Données invalides", http.StatusBadRequest) - return - } - - addLog("WiFi", fmt.Sprintf("Tentative de connexion à %s", req.SSID)) - - err := connectToWiFiDBus(req.SSID, req.Password) - if err != nil { - addLog("WiFi", fmt.Sprintf("Échec de connexion: %v", err)) - http.Error(w, fmt.Sprintf("Échec de connexion: %v", err), http.StatusInternalServerError) - return - } - - statusMutex.Lock() - currentStatus.Connected = true - currentStatus.ConnectedSSID = req.SSID - statusMutex.Unlock() - - addLog("WiFi", fmt.Sprintf("Connexion réussie à %s", req.SSID)) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "success"}) -} - -func disconnectWiFiHandler(w http.ResponseWriter, r *http.Request) { - addLog("WiFi", "Tentative de déconnexion") - - err := disconnectWiFiDBus() - if err != nil { - addLog("WiFi", fmt.Sprintf("Erreur de déconnexion: %v", err)) - http.Error(w, fmt.Sprintf("Erreur de déconnexion: %v", err), http.StatusInternalServerError) - return - } - - statusMutex.Lock() - currentStatus.Connected = false - currentStatus.ConnectedSSID = "" - statusMutex.Unlock() - - addLog("WiFi", "Déconnexion réussie") - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "success"}) -} - -func configureHotspotHandler(w http.ResponseWriter, r *http.Request) { - var config HotspotConfig - if err := json.NewDecoder(r.Body).Decode(&config); err != nil { - http.Error(w, "Données invalides", http.StatusBadRequest) - return - } - - err := configureHotspot(config) - if err != nil { - addLog("Hotspot", fmt.Sprintf("Erreur de configuration: %v", err)) - http.Error(w, fmt.Sprintf("Erreur de configuration: %v", err), http.StatusInternalServerError) - return - } - - addLog("Hotspot", fmt.Sprintf("Configuration mise à jour: %s (Canal %d)", config.SSID, config.Channel)) - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "success"}) -} - -func toggleHotspotHandler(w http.ResponseWriter, r *http.Request) { - statusMutex.Lock() - currentStatus.HotspotEnabled = !currentStatus.HotspotEnabled - enabled := currentStatus.HotspotEnabled - statusMutex.Unlock() - - var err error - if enabled { - err = startHotspot() - addLog("Hotspot", "Hotspot activé") - } else { - err = stopHotspot() - addLog("Hotspot", "Hotspot désactivé") - } - - if err != nil { - addLog("Hotspot", fmt.Sprintf("Erreur: %v", err)) - http.Error(w, fmt.Sprintf("Erreur: %v", err), http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]bool{"enabled": enabled}) -} - -func getDevicesHandler(w http.ResponseWriter, r *http.Request) { - devices, err := getConnectedDevices() - if err != nil { - addLog("Système", fmt.Sprintf("Erreur lors de la récupération des appareils: %v", err)) - http.Error(w, "Erreur lors de la récupération des appareils", http.StatusInternalServerError) - return - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(devices) -} - -func getStatusHandler(w http.ResponseWriter, r *http.Request) { - statusMutex.RLock() - status := currentStatus - status.Uptime = int64(time.Since(startTime).Seconds()) - statusMutex.RUnlock() - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(status) -} - -func getLogsHandler(w http.ResponseWriter, r *http.Request) { - logMutex.RLock() - logs := make([]LogEntry, len(logEntries)) - copy(logs, logEntries) - logMutex.RUnlock() - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(logs) -} - -func clearLogsHandler(w http.ResponseWriter, r *http.Request) { - logMutex.Lock() - logEntries = []LogEntry{} - logMutex.Unlock() - - addLog("Système", "Logs effacés") - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]string{"status": "success"}) -} - -// Fonctions WiFi avec D-Bus - -func scanWiFiNetworks() ([]WiFiNetwork, error) { - interfacePath, err := getWiFiInterfacePath() - if err != nil { - return nil, fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err) - } - - // Déclencher un scan - wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath) - call := wifiInterface.Call(WPA_INTERFACE_IFACE+".Scan", 0, map[string]dbus.Variant{"Type": dbus.MakeVariant("active")}) - if call.Err != nil { - return nil, fmt.Errorf("erreur lors du scan: %v", call.Err) - } - - // Attendre un peu pour que le scan se termine - time.Sleep(2 * time.Second) - - // Récupérer la liste des BSS - bssePaths, err := wifiInterface.GetProperty(WPA_INTERFACE_IFACE + ".BSSs") - if err != nil { - return nil, fmt.Errorf("erreur lors de la récupération des BSS: %v", err) - } - - var networks []WiFiNetwork - seenSSIDs := make(map[string]bool) - - for _, bssPath := range bssePaths.Value().([]dbus.ObjectPath) { - bss := dbusConn.Object(WPA_SUPPLICANT_SERVICE, bssPath) - - // Récupérer les propriétés du BSS - var props map[string]dbus.Variant - err = bss.Call("org.freedesktop.DBus.Properties.GetAll", 0, WPA_BSS_IFACE).Store(&props) - if err != nil { - continue - } - - network := WiFiNetwork{} - - // Extraire SSID - if ssidBytes, ok := props["SSID"].Value().([]byte); ok { - network.SSID = string(ssidBytes) - } - - // Éviter les doublons - if network.SSID == "" || seenSSIDs[network.SSID] { - continue - } - seenSSIDs[network.SSID] = true - - // Extraire BSSID - if bssidBytes, ok := props["BSSID"].Value().([]byte); ok { - network.BSSID = fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", - bssidBytes[0], bssidBytes[1], bssidBytes[2], bssidBytes[3], bssidBytes[4], bssidBytes[5]) - } - - // Extraire la force du signal - if signal, ok := props["Signal"].Value().(int16); ok { - network.Signal = signalToStrength(int(signal)) - } - - // Extraire la fréquence et calculer le canal - if frequency, ok := props["Frequency"].Value().(uint16); ok { - network.Channel = frequencyToChannel(int(frequency)) - } - - // Déterminer la sécurité - if privacyVal, ok := props["Privacy"].Value().(bool); ok && privacyVal { - if wpaProps, ok := props["WPA"].Value().(map[string]dbus.Variant); ok && len(wpaProps) > 0 { - network.Security = "WPA" - } else if rsnProps, ok := props["RSN"].Value().(map[string]dbus.Variant); ok && len(rsnProps) > 0 { - network.Security = "WPA2" - } else { - network.Security = "WEP" - } - } else { - network.Security = "Open" - } - - networks = append(networks, network) - } - - // Trier par force du signal - sort.Slice(networks, func(i, j int) bool { - return networks[i].Signal > networks[j].Signal - }) - - return networks, nil -} - -func connectToWiFiDBus(ssid, password string) error { - interfacePath, err := getWiFiInterfacePath() - if err != nil { - return fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err) - } - - wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath) - - // Créer un nouveau réseau - networkConfig := map[string]dbus.Variant{ - "ssid": dbus.MakeVariant(ssid), - } - - if password != "" { - networkConfig["psk"] = dbus.MakeVariant(password) - } - - var networkPath dbus.ObjectPath - err = wifiInterface.Call(WPA_INTERFACE_IFACE+".AddNetwork", 0, networkConfig).Store(&networkPath) - if err != nil { - return fmt.Errorf("erreur lors de l'ajout du réseau: %v", err) - } - - // Sélectionner le réseau - err = wifiInterface.Call(WPA_INTERFACE_IFACE+".SelectNetwork", 0, networkPath).Err - if err != nil { - return fmt.Errorf("erreur lors de la sélection du réseau: %v", err) - } - - // Attendre la connexion - for i := 0; i < 20; i++ { - time.Sleep(500 * time.Millisecond) - if isConnectedDBus() { - return nil - } - } - - return fmt.Errorf("timeout lors de la connexion") -} - -func disconnectWiFiDBus() error { - interfacePath, err := getWiFiInterfacePath() - if err != nil { - return fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err) - } - - wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath) - - // Déconnecter - err = wifiInterface.Call(WPA_INTERFACE_IFACE+".Disconnect", 0).Err - if err != nil { - return fmt.Errorf("erreur lors de la déconnexion: %v", err) - } - - // Supprimer tous les réseaux - var networks []dbus.ObjectPath - err = wifiInterface.Call(WPA_INTERFACE_IFACE+".Get", 0, WPA_INTERFACE_IFACE, "Networks").Store(&networks) - if err == nil { - for _, networkPath := range networks { - wifiInterface.Call(WPA_INTERFACE_IFACE+".RemoveNetwork", 0, networkPath) - } - } - - return nil -} - -func getWiFiInterfacePath() (dbus.ObjectPath, error) { - var interfacePath dbus.ObjectPath - err := wpaSupplicant.Call(WPA_SUPPLICANT_IFACE+".GetInterface", 0, WLAN_INTERFACE).Store(&interfacePath) - if err != nil { - return "", fmt.Errorf("erreur lors de la récupération des interfaces: %v", err) - } - - return interfacePath, nil -} - -func isConnectedDBus() bool { - interfacePath, err := getWiFiInterfacePath() - if err != nil { - return false - } - - wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath) - var state string - err = wifiInterface.Call(WPA_INTERFACE_IFACE+".Get", 0, WPA_INTERFACE_IFACE, "State").Store(&state) - if err != nil { - return false - } - - return state == "completed" -} - -func frequencyToChannel(frequency int) int { - if frequency >= 2412 && frequency <= 2484 { - if frequency == 2484 { - return 14 - } - return (frequency-2412)/5 + 1 - } else if frequency >= 5170 && frequency <= 5825 { - return (frequency - 5000) / 5 - } - return 0 -} - -func signalToStrength(level int) int { - if level >= -30 { - return 5 - } else if level >= -50 { - return 4 - } else if level >= -60 { - return 3 - } else if level >= -70 { - return 2 - } else { - return 1 - } -} - -func connectToWiFi(ssid, password string) error { - // Créer la configuration wpa_supplicant - config := fmt.Sprintf(`ctrl_interface=DIR=/var/run/wpa_supplicant GROUP=netdev -update_config=1 -country=FR - -network={ - ssid="%s" - psk="%s" -} -`, ssid, password) - - err := os.WriteFile(WPA_CONF, []byte(config), 0600) - if err != nil { - return err - } - - // Redémarrer wpa_supplicant - cmd := exec.Command("systemctl", "restart", "wpa_supplicant") - if err := cmd.Run(); err != nil { - return err - } - - // Attendre la connexion - for i := 0; i < 10; i++ { - time.Sleep(1 * time.Second) - if isConnected() { - return nil - } - } - - return fmt.Errorf("timeout lors de la connexion") -} - -func isConnected() bool { - cmd := exec.Command("iwconfig", WLAN_INTERFACE) - output, err := cmd.Output() - if err != nil { - return false - } - - return strings.Contains(string(output), "Access Point:") -} - -// Fonctions Hotspot - -func configureHotspot(config HotspotConfig) error { - hostapdConfig := fmt.Sprintf(`interface=%s -driver=nl80211 -ssid=%s -hw_mode=g -channel=%d -wmm_enabled=0 -macaddr_acl=0 -auth_algs=1 -ignore_broadcast_ssid=0 -wpa=2 -wpa_passphrase=%s -wpa_key_mgmt=WPA-PSK -wpa_pairwise=TKIP -rsn_pairwise=CCMP -`, AP_INTERFACE, config.SSID, config.Channel, config.Password) - - return os.WriteFile(HOSTAPD_CONF, []byte(hostapdConfig), 0644) -} - -func startHotspot() error { - cmd := exec.Command("systemctl", "start", "hostapd") - return cmd.Run() -} - -func stopHotspot() error { - cmd := exec.Command("systemctl", "stop", "hostapd") - return cmd.Run() -} - -// Fonctions pour les appareils connectés - -func getConnectedDevices() ([]ConnectedDevice, error) { - var devices []ConnectedDevice - - // Lire les baux DHCP - leases, err := parseDHCPLeases() - if err != nil { - return devices, err - } - - // Obtenir les informations ARP - arpInfo, err := getARPInfo() - if err != nil { - return devices, err - } - - for _, lease := range leases { - device := ConnectedDevice{ - Name: lease.Hostname, - MAC: lease.MAC, - IP: lease.IP, - Type: guessDeviceType(lease.Hostname, lease.MAC), - } - - // Vérifier si l'appareil est toujours connecté via ARP - if _, exists := arpInfo[lease.IP]; exists { - devices = append(devices, device) - } - } - - return devices, nil -} - -type DHCPLease struct { - IP string - MAC string - Hostname string -} - -func parseDHCPLeases() ([]DHCPLease, error) { - var leases []DHCPLease - - file, err := os.Open("/var/lib/dhcp/dhcpd.leases") - if err != nil { - return leases, err - } - defer file.Close() - - scanner := bufio.NewScanner(file) - var currentLease DHCPLease - - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - - if strings.HasPrefix(line, "lease ") { - ip := strings.Fields(line)[1] - currentLease = DHCPLease{IP: ip} - } else if strings.Contains(line, "hardware ethernet") { - mac := strings.Fields(line)[2] - mac = strings.TrimSuffix(mac, ";") - currentLease.MAC = mac - } else if strings.Contains(line, "client-hostname") { - hostname := strings.Fields(line)[1] - hostname = strings.Trim(hostname, `";`) - currentLease.Hostname = hostname - } else if line == "}" { - if currentLease.IP != "" && currentLease.MAC != "" { - leases = append(leases, currentLease) - } - currentLease = DHCPLease{} - } - } - - return leases, nil -} - -func getARPInfo() (map[string]string, error) { - arpInfo := make(map[string]string) - - cmd := exec.Command("arp", "-a") - output, err := cmd.Output() - if err != nil { - return arpInfo, err - } - - lines := strings.Split(string(output), "\n") - for _, line := range lines { - if matches := regexp.MustCompile(`\(([^)]+)\) at ([0-9a-fA-F:]{17})`).FindStringSubmatch(line); len(matches) > 2 { - ip := matches[1] - mac := matches[2] - arpInfo[ip] = mac - } - } - - return arpInfo, nil -} - -func guessDeviceType(hostname, mac string) string { - hostname = strings.ToLower(hostname) - - if strings.Contains(hostname, "iphone") || strings.Contains(hostname, "android") { - return "mobile" - } else if strings.Contains(hostname, "ipad") || strings.Contains(hostname, "tablet") { - return "tablet" - } else if strings.Contains(hostname, "macbook") || strings.Contains(hostname, "laptop") { - return "laptop" - } - - // Deviner par préfixe MAC (OUI) - macPrefix := strings.ToUpper(mac[:8]) - switch macPrefix { - case "00:50:56", "00:0C:29", "00:05:69": // VMware - return "laptop" - case "08:00:27": // VirtualBox - return "laptop" - default: - return "mobile" - } -} - -// Fonctions de logging - -func addLog(source, message string) { - logMutex.Lock() - entry := LogEntry{ - Timestamp: time.Now(), - Source: source, - Message: message, - } - logEntries = append(logEntries, entry) - - // Garder seulement les 100 derniers logs - if len(logEntries) > 100 { - logEntries = logEntries[len(logEntries)-100:] - } - logMutex.Unlock() - - // Envoyer aux clients WebSocket - broadcastToWebSockets(entry) - - // Log vers la console - log.Printf("[%s] %s", source, message) -} - -// WebSocket pour les logs en temps réel - -func websocketHandler(w http.ResponseWriter, r *http.Request) { - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - log.Printf("Erreur WebSocket: %v", err) - return - } - defer conn.Close() - - clientsMutex.Lock() - websocketClients[conn] = true - clientsMutex.Unlock() - - defer func() { - clientsMutex.Lock() - delete(websocketClients, conn) - clientsMutex.Unlock() - }() - - // Envoyer les logs existants - logMutex.RLock() - for _, entry := range logEntries { - conn.WriteJSON(entry) - } - logMutex.RUnlock() - - // Maintenir la connexion - for { - _, _, err := conn.ReadMessage() - if err != nil { - break - } - } -} - -func broadcastToWebSockets(entry LogEntry) { - clientsMutex.RLock() - defer clientsMutex.RUnlock() - - for client := range websocketClients { - err := client.WriteJSON(entry) - if err != nil { - client.Close() - delete(websocketClients, client) - } - } -} - -// Tâches périodiques - -func periodicStatusUpdate() { - ticker := time.NewTicker(5 * time.Second) - defer ticker.Stop() - - for range ticker.C { - statusMutex.Lock() - currentStatus.Connected = isConnected() - if !currentStatus.Connected { - currentStatus.ConnectedSSID = "" - } - statusMutex.Unlock() - } -} - -func periodicDeviceUpdate() { - ticker := time.NewTicker(10 * time.Second) - defer ticker.Stop() - - for range ticker.C { - devices, err := getConnectedDevices() - if err != nil { - continue - } - - statusMutex.Lock() - currentStatus.ConnectedDevices = devices - currentStatus.ConnectedCount = len(devices) - statusMutex.Unlock() - } -} diff --git a/static/app.js b/static/app.js deleted file mode 100644 index 6fce809..0000000 --- a/static/app.js +++ /dev/null @@ -1,239 +0,0 @@ -// État global de l'application -let appState = { - selectedWifi: null, - hotspotEnabled: true, - connectedDevices: [], - wifiNetworks: [], - uptime: 0, - dataUsage: 0 -}; - -// Simulation de données -const mockDevices = [ - { name: "iPhone 13", type: "mobile", mac: "AA:BB:CC:DD:EE:FF", ip: "192.168.1.101" }, - { name: "MacBook Pro", type: "laptop", mac: "11:22:33:44:55:66", ip: "192.168.1.102" }, - { name: "iPad", type: "tablet", mac: "77:88:99:AA:BB:CC", ip: "192.168.1.103" } -]; - -// Initialisation -document.addEventListener('DOMContentLoaded', function() { - initializeApp(); - startPeriodicUpdates(); -}); - -function initializeApp() { - updateWifiList(); - updateDevicesList(); - updateStats(); - addLog("Système", "Interface web initialisée"); -} - -async function updateWifiList() { - const wifiList = document.getElementById('wifiList'); - wifiList.innerHTML = ''; - - (await (await fetch('/api/wifi/scan')).json()).forEach((network, index) => { - const wifiItem = document.createElement('div'); - wifiItem.className = 'wifi-item'; - wifiItem.onclick = () => selectWifi(network, wifiItem); - - wifiItem.innerHTML = ` -
- ${network.ssid} -
${network.security} • Canal ${network.channel}
-
-
- ${generateSignalBars(network.signal)} -
- `; - - wifiList.appendChild(wifiItem); - }); -} - -function generateSignalBars(strength) { - const bars = []; - for (let i = 1; i <= 5; i++) { - const height = i * 3; - const active = i <= strength ? 'active' : ''; - bars.push(`
`); - } - return `
${bars.join('')}
`; -} - -function selectWifi(network, element) { - // Retirer la sélection précédente - document.querySelectorAll('.wifi-item').forEach(item => { - item.classList.remove('selected'); - }); - - // Ajouter la sélection - element.classList.add('selected'); - appState.selectedWifi = network; - - addLog("WiFi", `Réseau sélectionné: ${network.ssid}`); -} - -function updateDevicesList() { - const devicesList = document.getElementById('devicesList'); - devicesList.innerHTML = ''; - - mockDevices.forEach(device => { - const deviceCard = document.createElement('div'); - deviceCard.className = 'device-card'; - - const deviceIcon = getDeviceIcon(device.type); - - deviceCard.innerHTML = ` - ${deviceIcon} -
${device.name}
-
${device.ip}
- `; - - devicesList.appendChild(deviceCard); - }); - - document.getElementById('connectedDevices').textContent = mockDevices.length; -} - -function getDeviceIcon(type) { - const icons = { - mobile: '', - laptop: '', - tablet: '' - }; - return icons[type] || icons.mobile; -} - -function updateStats() { - appState.uptime += 1; - appState.dataUsage += Math.random() * 0.5; - - const hours = Math.floor(appState.uptime / 3600); - const minutes = Math.floor((appState.uptime % 3600) / 60); - const seconds = appState.uptime % 60; - - document.getElementById('uptime').textContent = - `${hours.toString().padStart(2, '0')}:${minutes.toString().padStart(2, '0')}:${seconds.toString().padStart(2, '0')}`; - - document.getElementById('dataUsage').textContent = `${appState.dataUsage.toFixed(1)} MB`; -} - -function addLog(source, message) { - const logContainer = document.getElementById('logContainer'); - const timestamp = new Date().toLocaleTimeString(); - const logEntry = document.createElement('div'); - logEntry.className = 'log-entry'; - logEntry.innerHTML = `[${timestamp}] ${source}: ${message}`; - - logContainer.appendChild(logEntry); - logContainer.scrollTop = logContainer.scrollHeight; -} - -function showNotification(message, type = 'success') { - const notification = document.getElementById('notification'); - notification.textContent = message; - notification.className = `notification ${type}`; - notification.classList.add('show'); - - setTimeout(() => { - notification.classList.remove('show'); - }, 3000); -} - -// Fonctions d'action -function scanWifi() { - const scanBtn = document.getElementById('scanBtn'); - const originalText = scanBtn.textContent; - - scanBtn.innerHTML = '
Scan en cours...'; - - setTimeout(() => { - updateWifiList(); - scanBtn.textContent = originalText; - showNotification('Scan terminé - Réseaux mis à jour'); - addLog("WiFi", "Scan des réseaux terminé"); - }, 2000); -} - -function connectToWifi() { - if (!appState.selectedWifi) { - showNotification('Veuillez sélectionner un réseau WiFi', 'error'); - return; - } - - const password = document.getElementById('wifiPassword').value; - if (!password && appState.selectedWifi.security !== 'Open') { - showNotification('Mot de passe requis', 'error'); - return; - } - - const connectBtn = document.getElementById('connectBtn'); - const originalText = connectBtn.textContent; - - connectBtn.innerHTML = '
Connexion...'; - - setTimeout(() => { - connectBtn.textContent = originalText; - showNotification(`Connecté à ${appState.selectedWifi.ssid}`); - addLog("WiFi", `Connexion établie avec ${appState.selectedWifi.ssid}`); - - // Mettre à jour le statut - document.getElementById('connectionStatus').innerHTML = ` -
- Connecté à ${appState.selectedWifi.ssid} - `; - }, 3000); -} - -function updateHotspot() { - const name = document.getElementById('hotspotName').value; - const password = document.getElementById('hotspotPassword').value; - const channel = document.getElementById('hotspotChannel').value; - - if (!name || !password) { - showNotification('Nom et mot de passe requis', 'error'); - return; - } - - showNotification('Configuration du hotspot mise à jour'); - addLog("Hotspot", `Configuration mise à jour: ${name} (Canal ${channel})`); -} - -function toggleHotspot() { - appState.hotspotEnabled = !appState.hotspotEnabled; - const btn = document.getElementById('hotspotBtn'); - - if (appState.hotspotEnabled) { - btn.textContent = 'Arrêter le hotspot'; - showNotification('Hotspot activé'); - addLog("Hotspot", "Hotspot activé"); - } else { - btn.textContent = 'Démarrer le hotspot'; - showNotification('Hotspot désactivé'); - addLog("Hotspot", "Hotspot désactivé"); - } -} - -function clearLogs() { - document.getElementById('logContainer').innerHTML = ''; - addLog("Système", "Logs effacés"); -} - -// Mises à jour périodiques -function startPeriodicUpdates() { - setInterval(updateStats, 1000); - setInterval(() => { - // Simulation de nouveaux logs - if (Math.random() > 0.95) { - const events = [ - "Nouveau client connecté", - "Paquet routé vers l'extérieur", - "Vérification de la connexion", - "Mise à jour des tables de routage" - ]; - const randomEvent = events[Math.floor(Math.random() * events.length)]; - addLog("Système", randomEvent); - } - }, 5000); -} diff --git a/static/index.html b/static/index.html deleted file mode 100644 index dbb7131..0000000 --- a/static/index.html +++ /dev/null @@ -1,135 +0,0 @@ - - - - - - WiFi Repeater Control - - - -
-
-

🌐 WiFi Repeater Control

-
-
- En ligne -
-
- -
-
-
0
-
Appareils connectés
-
-
-
0 MB
-
Données utilisées
-
-
-
00:00:00
-
Temps de fonctionnement
-
-
- -
-
-

- - - - Connexion WiFi Externe -

- -
- -
- -
-
- -
- - -
- - - - -
- -
-

- - - - Configuration Hotspot -

- -
- - -
- -
- - -
- -
- - -
- - - -
-
- -
-
-

- - - - Appareils connectés -

- -
- -
-
- -
-

- - - - Logs système -

- -
- -
- - -
-
-
- -
- - - - diff --git a/static/style.css b/static/style.css deleted file mode 100644 index 3ea544a..0000000 --- a/static/style.css +++ /dev/null @@ -1,338 +0,0 @@ -* { - margin: 0; - padding: 0; - box-sizing: border-box; -} - -body { - font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - min-height: 100vh; - padding: 20px; -} - -.container { - max-width: 1200px; - margin: 0 auto; - background: rgba(255, 255, 255, 0.95); - backdrop-filter: blur(10px); - border-radius: 20px; - padding: 30px; - box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1); -} - -.header { - text-align: center; - margin-bottom: 40px; - padding-bottom: 20px; - border-bottom: 2px solid #e0e0e0; -} - -.header h1 { - color: #333; - font-size: 2.5em; - margin-bottom: 10px; -} - -.status-indicator { - display: inline-flex; - align-items: center; - gap: 10px; - padding: 10px 20px; - border-radius: 25px; - font-weight: 500; - margin-top: 10px; -} - -.status-online { - background: #d4edda; - color: #155724; -} - -.status-offline { - background: #f8d7da; - color: #721c24; -} - -.status-dot { - width: 12px; - height: 12px; - border-radius: 50%; - background: currentColor; - animation: pulse 2s infinite; -} - -@keyframes pulse { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.5; } -} - -.grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(400px, 1fr)); - gap: 30px; - margin-bottom: 30px; -} - -.card { - background: white; - border-radius: 15px; - padding: 25px; - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.1); - transition: transform 0.3s ease, box-shadow 0.3s ease; -} - -.card:hover { - transform: translateY(-5px); - box-shadow: 0 20px 40px rgba(0, 0, 0, 0.15); -} - -.card h2 { - color: #333; - margin-bottom: 20px; - font-size: 1.4em; - display: flex; - align-items: center; - gap: 10px; -} - -.icon { - width: 24px; - height: 24px; - fill: currentColor; -} - -.form-group { - margin-bottom: 20px; -} - -.form-group label { - display: block; - margin-bottom: 8px; - color: #555; - font-weight: 500; -} - -.form-group input, .form-group select { - width: 100%; - padding: 12px 16px; - border: 2px solid #e0e0e0; - border-radius: 8px; - font-size: 16px; - transition: border-color 0.3s ease; -} - -.form-group input:focus, .form-group select:focus { - outline: none; - border-color: #667eea; -} - -.btn { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - border: none; - padding: 12px 24px; - border-radius: 8px; - font-size: 16px; - font-weight: 500; - cursor: pointer; - transition: transform 0.2s ease, box-shadow 0.2s ease; - width: 100%; -} - -.btn:hover { - transform: translateY(-2px); - box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3); -} - -.btn:active { - transform: translateY(0); -} - -.btn-secondary { - background: #6c757d; - margin-top: 10px; -} - -.btn-danger { - background: #dc3545; -} - -.wifi-list { - max-height: 300px; - overflow-y: auto; - border: 1px solid #e0e0e0; - border-radius: 8px; - margin-bottom: 20px; -} - -.wifi-item { - padding: 12px 16px; - border-bottom: 1px solid #f0f0f0; - cursor: pointer; - transition: background-color 0.2s ease; - display: flex; - justify-content: space-between; - align-items: center; -} - -.wifi-item:hover { - background-color: #f8f9fa; -} - -.wifi-item:last-child { - border-bottom: none; -} - -.wifi-item.selected { - background-color: #e7f3ff; - border-left: 4px solid #667eea; -} - -.wifi-signal { - display: flex; - align-items: center; - gap: 8px; -} - -.signal-strength { - width: 20px; - height: 20px; - position: relative; -} - -.signal-bars { - display: flex; - gap: 2px; - align-items: flex-end; -} - -.signal-bar { - width: 3px; - background: #ccc; - border-radius: 1px; -} - -.signal-bar.active { - background: #28a745; -} - -.devices-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); - gap: 15px; -} - -.device-card { - background: #f8f9fa; - border-radius: 10px; - padding: 15px; - text-align: center; - transition: transform 0.2s ease; -} - -.device-card:hover { - transform: scale(1.05); -} - -.device-icon { - width: 40px; - height: 40px; - margin: 0 auto 10px; - fill: #667eea; -} - -.log-container { - background: #1a1a1a; - color: #00ff00; - padding: 20px; - border-radius: 10px; - font-family: 'Courier New', monospace; - font-size: 14px; - max-height: 300px; - overflow-y: auto; - line-height: 1.4; -} - -.log-entry { - margin-bottom: 5px; -} - -.log-timestamp { - color: #888; -} - -.stats-grid { - display: grid; - grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); - gap: 20px; -} - -.stat-card { - background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); - color: white; - padding: 20px; - border-radius: 10px; - text-align: center; -} - -.stat-value { - font-size: 2em; - font-weight: bold; - margin-bottom: 5px; -} - -.stat-label { - font-size: 0.9em; - opacity: 0.9; -} - -.loading { - display: inline-block; - width: 20px; - height: 20px; - border: 3px solid #f3f3f3; - border-top: 3px solid #667eea; - border-radius: 50%; - animation: spin 1s linear infinite; -} - -@keyframes spin { - 0% { transform: rotate(0deg); } - 100% { transform: rotate(360deg); } -} - -.notification { - position: fixed; - top: 20px; - right: 20px; - padding: 15px 20px; - border-radius: 10px; - color: white; - font-weight: 500; - z-index: 1000; - transform: translateX(100%); - transition: transform 0.3s ease; -} - -.notification.show { - transform: translateX(0); -} - -.notification.success { - background: #28a745; -} - -.notification.error { - background: #dc3545; -} - -@media (max-width: 768px) { - .grid { - grid-template-columns: 1fr; - } - - .container { - padding: 20px; - } -} From accd7e75d81acd569992f48eaf5fab6e0053eba8 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Tue, 28 Oct 2025 19:23:39 +0700 Subject: [PATCH 03/13] Handle config options --- cmd/repeater/main.go | 9 ++++- internal/config/cli.go | 24 ++++++++++++ internal/config/config.go | 79 +++++++++++++++++++++++++++++++++++++++ internal/config/custom.go | 27 +++++++++++++ internal/config/env.go | 21 +++++++++++ internal/config/file.go | 33 ++++++++++++++++ 6 files changed, 192 insertions(+), 1 deletion(-) create mode 100644 internal/config/cli.go create mode 100644 internal/config/config.go create mode 100644 internal/config/custom.go create mode 100644 internal/config/env.go create mode 100644 internal/config/file.go diff --git a/cmd/repeater/main.go b/cmd/repeater/main.go index 20752c8..aee995e 100644 --- a/cmd/repeater/main.go +++ b/cmd/repeater/main.go @@ -8,12 +8,19 @@ import ( "syscall" "github.com/nemunaire/repeater/internal/app" + "github.com/nemunaire/repeater/internal/config" ) //go:embed all:static var assets embed.FS func main() { + // Load and parse options + cfg, err := config.ConsolidateConfig() + if err != nil { + log.Fatal(err) + } + // Create application instance application := app.New(assets) @@ -34,7 +41,7 @@ func main() { }() // Start the server - if err := application.Run(":8080"); err != nil { + if err := application.Run(cfg.Bind); err != nil { log.Fatalf("Failed to start server: %v", err) } } diff --git a/internal/config/cli.go b/internal/config/cli.go new file mode 100644 index 0000000..0cb90c6 --- /dev/null +++ b/internal/config/cli.go @@ -0,0 +1,24 @@ +package config + +import ( + "flag" +) + +// declareFlags registers flags for the structure Options. +func declareFlags(o *Config) { + flag.StringVar(&o.Bind, "bind", ":8081", "Bind port/socket") +} + +// parseCLI parse the flags and treats extra args as configuration filename. +func parseCLI(o *Config) error { + flag.Parse() + + for _, conf := range flag.Args() { + err := parseFile(o, conf) + if err != nil { + return err + } + } + + return nil +} diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..aed3693 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,79 @@ +package config + +import ( + "flag" + "log" + "os" + "path" + "strings" +) + +type Config struct { + Bind string +} + +// ConsolidateConfig fills an Options struct by reading configuration from +// config files, environment, then command line. +// +// Should be called only one time. +func ConsolidateConfig() (opts *Config, err error) { + // Define defaults options + opts = &Config{ + Bind: ":8080", + } + + declareFlags(opts) + + // Establish a list of possible configuration file locations + configLocations := []string{ + "repeater.conf", + } + + if home, err := os.UserConfigDir(); err == nil { + configLocations = append(configLocations, path.Join(home, "repeater", "repeater.conf")) + } + + configLocations = append(configLocations, path.Join("etc", "repeater.conf")) + + // If config file exists, read configuration from it + for _, filename := range configLocations { + if _, e := os.Stat(filename); !os.IsNotExist(e) { + log.Printf("Loading configuration from %s\n", filename) + err = parseFile(opts, filename) + if err != nil { + return + } + break + } + } + + // Then, overwrite that by what is present in the environment + err = parseEnvironmentVariables(opts) + if err != nil { + return + } + + // Finaly, command line takes precedence + err = parseCLI(opts) + if err != nil { + return + } + + return +} + +// parseLine treats a config line and place the read value in the variable +// declared to the corresponding flag. +func parseLine(o *Config, line string) (err error) { + fields := strings.SplitN(line, "=", 2) + orig_key := strings.TrimSpace(fields[0]) + value := strings.TrimSpace(fields[1]) + + key := strings.TrimPrefix(orig_key, "REPEATER_") + key = strings.Replace(key, "_", "-", -1) + key = strings.ToLower(key) + + err = flag.Set(key, value) + + return +} diff --git a/internal/config/custom.go b/internal/config/custom.go new file mode 100644 index 0000000..71428fc --- /dev/null +++ b/internal/config/custom.go @@ -0,0 +1,27 @@ +package config + +import ( + "net/url" +) + +type URL struct { + URL *url.URL +} + +func (i *URL) String() string { + if i.URL != nil { + return i.URL.String() + } else { + return "" + } +} + +func (i *URL) Set(value string) error { + u, err := url.Parse(value) + if err != nil { + return err + } + + *i.URL = *u + return nil +} diff --git a/internal/config/env.go b/internal/config/env.go new file mode 100644 index 0000000..8888746 --- /dev/null +++ b/internal/config/env.go @@ -0,0 +1,21 @@ +package config + +import ( + "fmt" + "os" + "strings" +) + +// parseEnvironmentVariables analyzes all the environment variables to find +// each one starting by REPEATER_ +func parseEnvironmentVariables(o *Config) (err error) { + for _, line := range os.Environ() { + if strings.HasPrefix(line, "REPEATER_") { + err := parseLine(o, line) + if err != nil { + return fmt.Errorf("error in environment (%q): %w", line, err) + } + } + } + return +} diff --git a/internal/config/file.go b/internal/config/file.go new file mode 100644 index 0000000..8ba6a10 --- /dev/null +++ b/internal/config/file.go @@ -0,0 +1,33 @@ +package config + +import ( + "bufio" + "fmt" + "os" + "strings" +) + +// parseFile opens the file at the given filename path, then treat each line +// not starting with '#' as a configuration statement. +func parseFile(o *Config, filename string) error { + fp, err := os.Open(filename) + if err != nil { + return err + } + defer fp.Close() + + scanner := bufio.NewScanner(fp) + n := 0 + for scanner.Scan() { + n += 1 + line := strings.TrimSpace(scanner.Text()) + if len(line) > 0 && !strings.HasPrefix(line, "#") && strings.Index(line, "=") > 0 { + err := parseLine(o, line) + if err != nil { + return fmt.Errorf("%v:%d: error in configuration: %w", filename, n, err) + } + } + } + + return nil +} From 2b3a5b89f8ab8281660d7019b39103897a9e34e4 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Sun, 28 Dec 2025 18:51:15 +0700 Subject: [PATCH 04/13] Add configuration system and ARP-based device discovery MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement comprehensive configuration management with CLI flags for WiFi interface, device discovery method, and file paths. Add ARP table parsing as an alternative to DHCP leases for more reliable device detection. Improve WiFi scanning to handle concurrent scan requests gracefully. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- cmd/repeater/main.go | 2 +- internal/api/handlers/handlers.go | 5 +- internal/api/router.go | 7 +- internal/app/app.go | 13 ++-- internal/config/cli.go | 4 ++ internal/config/config.go | 12 +++- internal/device/device.go | 104 ++++++++++++++++++++++++++++-- internal/wifi/wifi.go | 39 +++++++---- 8 files changed, 157 insertions(+), 29 deletions(-) diff --git a/cmd/repeater/main.go b/cmd/repeater/main.go index aee995e..3fd1e66 100644 --- a/cmd/repeater/main.go +++ b/cmd/repeater/main.go @@ -25,7 +25,7 @@ func main() { application := app.New(assets) // Initialize the application - if err := application.Initialize(); err != nil { + if err := application.Initialize(cfg); err != nil { log.Fatalf("Failed to initialize application: %v", err) } defer application.Shutdown() diff --git a/internal/api/handlers/handlers.go b/internal/api/handlers/handlers.go index 49d6c3a..4bb2302 100644 --- a/internal/api/handlers/handlers.go +++ b/internal/api/handlers/handlers.go @@ -4,6 +4,7 @@ import ( "net/http" "github.com/gin-gonic/gin" + "github.com/nemunaire/repeater/internal/config" "github.com/nemunaire/repeater/internal/device" "github.com/nemunaire/repeater/internal/hotspot" "github.com/nemunaire/repeater/internal/logging" @@ -103,8 +104,8 @@ func ToggleHotspot(c *gin.Context, status *models.SystemStatus) { } // GetDevices returns connected devices -func GetDevices(c *gin.Context) { - devices, err := device.GetConnectedDevices() +func GetDevices(c *gin.Context, cfg *config.Config) { + devices, err := device.GetConnectedDevices(cfg) if err != nil { logging.AddLog("Système", "Erreur lors de la récupération des appareils: "+err.Error()) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la récupération des appareils"}) diff --git a/internal/api/router.go b/internal/api/router.go index 5558494..a06c328 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -7,11 +7,12 @@ import ( "github.com/gin-gonic/gin" "github.com/nemunaire/repeater/internal/api/handlers" + "github.com/nemunaire/repeater/internal/config" "github.com/nemunaire/repeater/internal/models" ) // SetupRouter creates and configures the Gin router -func SetupRouter(status *models.SystemStatus, assets embed.FS) *gin.Engine { +func SetupRouter(status *models.SystemStatus, cfg *config.Config, assets embed.FS) *gin.Engine { // Set Gin to release mode (can be overridden with GIN_MODE env var) gin.SetMode(gin.ReleaseMode) @@ -38,7 +39,9 @@ func SetupRouter(status *models.SystemStatus, assets embed.FS) *gin.Engine { } // Device endpoints - api.GET("/devices", handlers.GetDevices) + api.GET("/devices", func(c *gin.Context) { + handlers.GetDevices(c, cfg) + }) // Status endpoint api.GET("/status", func(c *gin.Context) { diff --git a/internal/app/app.go b/internal/app/app.go index 39181e6..e4d5d0e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -7,6 +7,7 @@ import ( "time" "github.com/nemunaire/repeater/internal/api" + "github.com/nemunaire/repeater/internal/config" "github.com/nemunaire/repeater/internal/device" "github.com/nemunaire/repeater/internal/logging" "github.com/nemunaire/repeater/internal/models" @@ -19,6 +20,7 @@ type App struct { StatusMutex sync.RWMutex StartTime time.Time Assets embed.FS + Config *config.Config } // New creates a new application instance @@ -38,9 +40,12 @@ func New(assets embed.FS) *App { } // Initialize initializes the application -func (a *App) Initialize() error { +func (a *App) Initialize(cfg *config.Config) error { + // Store config reference + a.Config = cfg + // Initialize WiFi D-Bus connection - if err := wifi.Initialize(); err != nil { + if err := wifi.Initialize(cfg.WifiInterface); err != nil { return err } @@ -54,7 +59,7 @@ func (a *App) Initialize() error { // Run starts the HTTP server func (a *App) Run(addr string) error { - router := api.SetupRouter(&a.Status, a.Assets) + router := api.SetupRouter(&a.Status, a.Config, a.Assets) logging.AddLog("Système", "Serveur API démarré sur "+addr) return router.Run(addr) @@ -88,7 +93,7 @@ func (a *App) periodicDeviceUpdate() { defer ticker.Stop() for range ticker.C { - devices, err := device.GetConnectedDevices() + devices, err := device.GetConnectedDevices(a.Config) if err != nil { log.Printf("Error getting connected devices: %v", err) continue diff --git a/internal/config/cli.go b/internal/config/cli.go index 0cb90c6..f61db13 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -7,6 +7,10 @@ import ( // declareFlags registers flags for the structure Options. func declareFlags(o *Config) { flag.StringVar(&o.Bind, "bind", ":8081", "Bind port/socket") + flag.StringVar(&o.WifiInterface, "wifi-interface", "wlan0", "WiFi interface name") + flag.BoolVar(&o.UseARPDiscovery, "use-arp-discovery", true, "Use ARP table for device discovery instead of DHCP leases") + flag.StringVar(&o.DHCPLeasesPath, "dhcp-leases-path", "/var/lib/dhcp/dhcpd.leases", "Path to DHCP leases file") + flag.StringVar(&o.ARPTablePath, "arp-table-path", "/proc/net/arp", "Path to ARP table file") } // parseCLI parse the flags and treats extra args as configuration filename. diff --git a/internal/config/config.go b/internal/config/config.go index aed3693..329354b 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,7 +9,11 @@ import ( ) type Config struct { - Bind string + Bind string + WifiInterface string + UseARPDiscovery bool + DHCPLeasesPath string + ARPTablePath string } // ConsolidateConfig fills an Options struct by reading configuration from @@ -19,7 +23,11 @@ type Config struct { func ConsolidateConfig() (opts *Config, err error) { // Define defaults options opts = &Config{ - Bind: ":8080", + Bind: ":8080", + WifiInterface: "wlan0", + UseARPDiscovery: true, + DHCPLeasesPath: "/var/lib/dhcp/dhcpd.leases", + ARPTablePath: "/proc/net/arp", } declareFlags(opts) diff --git a/internal/device/device.go b/internal/device/device.go index 5eaaf06..f57dfa5 100644 --- a/internal/device/device.go +++ b/internal/device/device.go @@ -2,25 +2,71 @@ package device import ( "bufio" + "fmt" + "net" "os" "os/exec" "regexp" "strings" + "github.com/nemunaire/repeater/internal/config" "github.com/nemunaire/repeater/internal/models" ) +// ARPEntry represents an entry in the ARP table +type ARPEntry struct { + IP net.IP + HWType int + Flags int + HWAddress net.HardwareAddr + Mask string + Device string +} + // GetConnectedDevices returns a list of connected devices -func GetConnectedDevices() ([]models.ConnectedDevice, error) { +func GetConnectedDevices(cfg *config.Config) ([]models.ConnectedDevice, error) { + if cfg.UseARPDiscovery { + return getDevicesFromARP(cfg) + } + return getDevicesFromDHCP(cfg) +} + +// getDevicesFromARP discovers devices using ARP table +func getDevicesFromARP(cfg *config.Config) ([]models.ConnectedDevice, error) { var devices []models.ConnectedDevice - // Read DHCP leases - leases, err := parseDHCPLeases() + arpEntries, err := parseARPTable(cfg.ARPTablePath) if err != nil { return devices, err } - // Get ARP information + for _, entry := range arpEntries { + // Only include entries with valid flags (2 = COMPLETE, 6 = COMPLETE|PERM) + if entry.Flags == 2 || entry.Flags == 6 { + device := models.ConnectedDevice{ + Name: "", // No hostname available from ARP + MAC: entry.HWAddress.String(), + IP: entry.IP.String(), + Type: guessDeviceType("", entry.HWAddress.String()), + } + devices = append(devices, device) + } + } + + return devices, nil +} + +// getDevicesFromDHCP discovers devices using DHCP leases and ARP validation +func getDevicesFromDHCP(cfg *config.Config) ([]models.ConnectedDevice, error) { + var devices []models.ConnectedDevice + + // Read DHCP leases + leases, err := parseDHCPLeases(cfg.DHCPLeasesPath) + if err != nil { + return devices, err + } + + // Get ARP information for validation arpInfo, err := getARPInfo() if err != nil { return devices, err @@ -43,11 +89,57 @@ func GetConnectedDevices() ([]models.ConnectedDevice, error) { return devices, nil } +// parseARPTable reads and parses ARP table from /proc/net/arp format +func parseARPTable(path string) ([]ARPEntry, error) { + var entries []ARPEntry + + content, err := os.ReadFile(path) + if err != nil { + return entries, err + } + + for _, line := range strings.Split(string(content), "\n") { + fields := strings.Fields(line) + if len(fields) > 5 { + var entry ARPEntry + + // Parse HWType (hex format) + if _, err := fmt.Sscanf(fields[1], "0x%x", &entry.HWType); err != nil { + continue + } + + // Parse Flags (hex format) + if _, err := fmt.Sscanf(fields[2], "0x%x", &entry.Flags); err != nil { + continue + } + + // Parse IP address + entry.IP = net.ParseIP(fields[0]) + if entry.IP == nil { + continue + } + + // Parse MAC address + entry.HWAddress, err = net.ParseMAC(fields[3]) + if err != nil { + continue + } + + entry.Mask = fields[4] + entry.Device = fields[5] + + entries = append(entries, entry) + } + } + + return entries, nil +} + // parseDHCPLeases reads and parses DHCP lease file -func parseDHCPLeases() ([]models.DHCPLease, error) { +func parseDHCPLeases(path string) ([]models.DHCPLease, error) { var leases []models.DHCPLease - file, err := os.Open("/var/lib/dhcp/dhcpd.leases") + file, err := os.Open(path) if err != nil { return leases, err } diff --git a/internal/wifi/wifi.go b/internal/wifi/wifi.go index e10731b..e7cb20d 100644 --- a/internal/wifi/wifi.go +++ b/internal/wifi/wifi.go @@ -12,8 +12,7 @@ import ( ) const ( - WLAN_INTERFACE = "wlan0" - WPA_CONF = "/etc/wpa_supplicant/wpa_supplicant.conf" + WPA_CONF = "/etc/wpa_supplicant/wpa_supplicant.conf" // D-Bus constants for wpa_supplicant WPA_SUPPLICANT_SERVICE = "fi.w1.wpa_supplicant1" @@ -25,12 +24,14 @@ const ( ) var ( + wlanInterface string dbusConn *dbus.Conn wpaSupplicant dbus.BusObject ) // Initialize initializes the WiFi service with D-Bus connection -func Initialize() error { +func Initialize(interfaceName string) error { + wlanInterface = interfaceName var err error dbusConn, err = dbus.SystemBus() if err != nil { @@ -55,15 +56,29 @@ func ScanNetworks() ([]models.WiFiNetwork, error) { return nil, fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err) } - // Trigger a scan wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath) - call := wifiInterface.Call(WPA_INTERFACE_IFACE+".Scan", 0, map[string]dbus.Variant{"Type": dbus.MakeVariant("active")}) - if call.Err != nil { - return nil, fmt.Errorf("erreur lors du scan: %v", call.Err) - } - // Wait for scan to complete - time.Sleep(2 * time.Second) + // Check current scanning state + scanning, err := wifiInterface.GetProperty(WPA_INTERFACE_IFACE + ".Scanning") + if err == nil && scanning.Value().(bool) { + // Scan already in progress, wait for it to complete + time.Sleep(3 * time.Second) + } else { + // Trigger a scan + call := wifiInterface.Call(WPA_INTERFACE_IFACE+".Scan", 0, map[string]dbus.Variant{"Type": dbus.MakeVariant("active")}) + if call.Err != nil { + // If scan is rejected, it might be too soon after a previous scan + // Try to use cached results instead + if strings.Contains(call.Err.Error(), "rejected") { + // Continue to retrieve existing BSS list + } else { + return nil, fmt.Errorf("erreur lors du scan: %v", call.Err) + } + } else { + // Wait for scan to complete + time.Sleep(2 * time.Second) + } + } // Retrieve BSS list bssePaths, err := wifiInterface.GetProperty(WPA_INTERFACE_IFACE + ".BSSs") @@ -224,7 +239,7 @@ func IsConnected() bool { // IsConnectedLegacy checks if WiFi is connected using iwconfig (fallback) func IsConnectedLegacy() bool { - cmd := exec.Command("iwconfig", WLAN_INTERFACE) + cmd := exec.Command("iwconfig", wlanInterface) output, err := cmd.Output() if err != nil { return false @@ -236,7 +251,7 @@ func IsConnectedLegacy() bool { // getWiFiInterfacePath retrieves the D-Bus path for the WiFi interface func getWiFiInterfacePath() (dbus.ObjectPath, error) { var interfacePath dbus.ObjectPath - err := wpaSupplicant.Call(WPA_SUPPLICANT_IFACE+".GetInterface", 0, WLAN_INTERFACE).Store(&interfacePath) + err := wpaSupplicant.Call(WPA_SUPPLICANT_IFACE+".GetInterface", 0, wlanInterface).Store(&interfacePath) if err != nil { return "", fmt.Errorf("erreur lors de la récupération des interfaces: %v", err) } From 17d665e21a7b8538843c477f724b57127342a143 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 1 Jan 2026 17:04:21 +0700 Subject: [PATCH 05/13] Implementation with iwd --- cmd/repeater/static/app.js | 51 +++++- cmd/repeater/static/style.css | 15 ++ internal/app/app.go | 61 ++++++- internal/wifi/iwd/agent.go | 170 ++++++++++++++++++ internal/wifi/iwd/agentmanager.go | 39 +++++ internal/wifi/iwd/manager.go | 71 ++++++++ internal/wifi/iwd/network.go | 64 +++++++ internal/wifi/iwd/station.go | 137 +++++++++++++++ internal/wifi/iwd/types.go | 38 ++++ internal/wifi/wifi.go | 282 +++++++++++++----------------- 10 files changed, 756 insertions(+), 172 deletions(-) create mode 100644 internal/wifi/iwd/agent.go create mode 100644 internal/wifi/iwd/agentmanager.go create mode 100644 internal/wifi/iwd/manager.go create mode 100644 internal/wifi/iwd/network.go create mode 100644 internal/wifi/iwd/station.go create mode 100644 internal/wifi/iwd/types.go diff --git a/cmd/repeater/static/app.js b/cmd/repeater/static/app.js index bd29eec..3813ab8 100644 --- a/cmd/repeater/static/app.js +++ b/cmd/repeater/static/app.js @@ -5,7 +5,11 @@ const appState = { autoScrollLogs: true, ws: null, reconnectAttempts: 0, - maxReconnectAttempts: 5 + maxReconnectAttempts: 5, + connectedSSID: null, + networks: [], + uptime: 0, + uptimeInterval: null }; // Initialize the application @@ -30,7 +34,8 @@ async function initializeApp() { // Start periodic updates startPeriodicUpdates(); - showToast('success', 'Connecté', 'Interface web chargée avec succès'); + // Start uptime counter + startUptimeCounter(); } // ===== API Functions ===== @@ -62,6 +67,7 @@ async function scanWifi() { const response = await fetch('/api/wifi/scan'); const networks = await response.json(); + appState.networks = networks; displayWifiNetworks(networks); showToast('success', 'Scan WiFi', `${networks.length} réseau(x) trouvé(s)`); } catch (error) { @@ -128,6 +134,10 @@ async function disconnectWifi() { if (response.ok) { showToast('success', 'Déconnecté', 'Déconnexion WiFi réussie'); await loadStatus(); + // Force refresh WiFi list to remove green highlighting + if (appState.networks.length > 0) { + displayWifiNetworks(appState.networks); + } } else { throw new Error('Disconnection failed'); } @@ -288,10 +298,22 @@ function updateStatusDisplay(status) { appState.hotspotEnabled = status.hotspotEnabled; + // Check if connectedSSID changed and refresh WiFi list if needed + const connectedSSIDChanged = appState.connectedSSID !== status.connectedSSID; + appState.connectedSSID = status.connectedSSID; + + if (connectedSSIDChanged && appState.networks.length > 0) { + displayWifiNetworks(appState.networks); + } + // Update stats document.getElementById('connectedDevices').textContent = status.connectedCount; document.getElementById('dataUsage').textContent = `${status.dataUsage.toFixed(1)} MB`; + + // Update uptime in state (will be incremented by the interval) + appState.uptime = status.uptime; document.getElementById('uptime').textContent = formatUptime(status.uptime); + document.getElementById('currentNetwork').textContent = status.connectedSSID || '-'; } @@ -307,15 +329,21 @@ function displayWifiNetworks(networks) { networks.forEach(network => { const wifiItem = document.createElement('div'); wifiItem.className = 'wifi-item'; + + // Mark the currently connected network + if (appState.connectedSSID && network.ssid === appState.connectedSSID) { + wifiItem.classList.add('connected'); + } + wifiItem.onclick = () => selectWifi(network, wifiItem); wifiItem.innerHTML = `
${escapeHtml(network.ssid)}
- ${network.security} - Canal ${network.channel} - ${network.bssid} + ${escapeHtml(network.security)} + Canal ${escapeHtml(String(network.channel))} + ${escapeHtml(network.bssid)}
@@ -533,3 +561,16 @@ function startPeriodicUpdates() { loadDevices(); }, 10000); } + +function startUptimeCounter() { + // Clear any existing interval + if (appState.uptimeInterval) { + clearInterval(appState.uptimeInterval); + } + + // Increment uptime every second + appState.uptimeInterval = setInterval(() => { + appState.uptime++; + document.getElementById('uptime').textContent = formatUptime(appState.uptime); + }, 1000); +} diff --git a/cmd/repeater/static/style.css b/cmd/repeater/static/style.css index 2dc82b0..e2de87c 100644 --- a/cmd/repeater/static/style.css +++ b/cmd/repeater/static/style.css @@ -130,6 +130,7 @@ body { gap: 1rem; align-items: center; transition: all 0.3s ease; + max-width: 100%; } .stat-card:hover { @@ -154,6 +155,7 @@ body { .stat-content { flex: 1; + min-width: 0; } .stat-value { @@ -162,6 +164,9 @@ body { color: var(--text-primary); line-height: 1; margin-bottom: 0.25rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; } .stat-label { @@ -413,6 +418,16 @@ body { border-left: 4px solid #667eea; } +.wifi-item.connected { + background: #d1fae5; + border-left-color: var(--success-color) !important; +} + +.wifi-item.connected .wifi-ssid { + color: var(--success-color); + font-weight: 700; +} + .wifi-item.loading { justify-content: center; color: var(--text-secondary); diff --git a/internal/app/app.go b/internal/app/app.go index e4d5d0e..3924f2a 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -3,6 +3,9 @@ package app import ( "embed" "log" + "os" + "strconv" + "strings" "sync" "time" @@ -71,6 +74,52 @@ func (a *App) Shutdown() { logging.AddLog("Système", "Application arrêtée") } +// getSystemUptime reads system uptime from /proc/uptime +func getSystemUptime() int64 { + data, err := os.ReadFile("/proc/uptime") + if err != nil { + log.Printf("Error reading /proc/uptime: %v", err) + return 0 + } + + fields := strings.Fields(string(data)) + if len(fields) == 0 { + return 0 + } + + uptime, err := strconv.ParseFloat(fields[0], 64) + if err != nil { + log.Printf("Error parsing uptime: %v", err) + return 0 + } + + return int64(uptime) +} + +// getInterfaceBytes reads rx and tx bytes for a network interface +func getInterfaceBytes(interfaceName string) (rxBytes, txBytes int64) { + rxPath := "/sys/class/net/" + interfaceName + "/statistics/rx_bytes" + txPath := "/sys/class/net/" + interfaceName + "/statistics/tx_bytes" + + // Read RX bytes + rxData, err := os.ReadFile(rxPath) + if err != nil { + log.Printf("Error reading rx_bytes for %s: %v", interfaceName, err) + } else { + rxBytes, _ = strconv.ParseInt(strings.TrimSpace(string(rxData)), 10, 64) + } + + // Read TX bytes + txData, err := os.ReadFile(txPath) + if err != nil { + log.Printf("Error reading tx_bytes for %s: %v", interfaceName, err) + } else { + txBytes, _ = strconv.ParseInt(strings.TrimSpace(string(txData)), 10, 64) + } + + return rxBytes, txBytes +} + // periodicStatusUpdate updates WiFi connection status periodically func (a *App) periodicStatusUpdate() { ticker := time.NewTicker(5 * time.Second) @@ -79,10 +128,16 @@ func (a *App) periodicStatusUpdate() { for range ticker.C { a.StatusMutex.Lock() a.Status.Connected = wifi.IsConnected() - if !a.Status.Connected { - a.Status.ConnectedSSID = "" + a.Status.ConnectedSSID = wifi.GetConnectedSSID() + a.Status.Uptime = getSystemUptime() + + // Get network data usage for WiFi interface + if a.Config != nil { + rxBytes, txBytes := getInterfaceBytes(a.Config.WifiInterface) + // Convert to MB and sum rx + tx + a.Status.DataUsage = float64(rxBytes+txBytes) / (1024 * 1024) } - a.Status.Uptime = int64(time.Since(a.StartTime).Seconds()) + a.StatusMutex.Unlock() } } diff --git a/internal/wifi/iwd/agent.go b/internal/wifi/iwd/agent.go new file mode 100644 index 0000000..3a53ba6 --- /dev/null +++ b/internal/wifi/iwd/agent.go @@ -0,0 +1,170 @@ +package iwd + +import ( + "fmt" + "sync" + + "github.com/godbus/dbus/v5" + "github.com/godbus/dbus/v5/introspect" +) + +const agentIntrospectXML = ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +` + +// Agent implements the net.connman.iwd.Agent interface for credential callbacks +type Agent struct { + conn *dbus.Conn + path dbus.ObjectPath + passphraseStore map[string]string + mu sync.RWMutex +} + +// NewAgent creates a new Agent instance +func NewAgent(conn *dbus.Conn, path dbus.ObjectPath) *Agent { + return &Agent{ + conn: conn, + path: path, + passphraseStore: make(map[string]string), + } +} + +// SetPassphrase stores a passphrase for a given network SSID +func (a *Agent) SetPassphrase(ssid, passphrase string) { + a.mu.Lock() + defer a.mu.Unlock() + a.passphraseStore[ssid] = passphrase +} + +// ClearPassphrase removes the stored passphrase for a network +func (a *Agent) ClearPassphrase(ssid string) { + a.mu.Lock() + defer a.mu.Unlock() + delete(a.passphraseStore, ssid) +} + +// Export registers the agent object on D-Bus +func (a *Agent) Export() error { + err := a.conn.Export(a, a.path, AgentInterface) + if err != nil { + return fmt.Errorf("failed to export agent: %v", err) + } + + err = a.conn.Export(introspect.Introspectable(agentIntrospectXML), a.path, "org.freedesktop.DBus.Introspectable") + if err != nil { + a.conn.Export(nil, a.path, AgentInterface) + return fmt.Errorf("failed to export introspection: %v", err) + } + + return nil +} + +// Unexport unregisters the agent from D-Bus +func (a *Agent) Unexport() { + a.conn.Export(nil, a.path, AgentInterface) + a.conn.Export(nil, a.path, "org.freedesktop.DBus.Introspectable") +} + +// getNetworkSSID queries the network object to get its SSID (Name property) +func (a *Agent) getNetworkSSID(networkPath dbus.ObjectPath) (string, error) { + obj := a.conn.Object(Service, networkPath) + variant, err := obj.GetProperty(NetworkInterface + ".Name") + if err != nil { + return "", fmt.Errorf("failed to get network name: %v", err) + } + + name, ok := variant.Value().(string) + if !ok { + return "", fmt.Errorf("network name is not a string") + } + + return name, nil +} + +// RequestPassphrase is called by iwd when connecting to PSK networks +func (a *Agent) RequestPassphrase(network dbus.ObjectPath) (string, *dbus.Error) { + fmt.Printf("[Agent] RequestPassphrase called for network: %s\n", network) + + ssid, err := a.getNetworkSSID(network) + if err != nil { + fmt.Printf("[Agent] Failed to get SSID: %v\n", err) + return "", dbus.MakeFailedError(fmt.Errorf("failed to get network SSID: %v", err)) + } + + fmt.Printf("[Agent] Network SSID: %s\n", ssid) + + a.mu.RLock() + passphrase, ok := a.passphraseStore[ssid] + a.mu.RUnlock() + + if !ok { + fmt.Printf("[Agent] No passphrase stored for SSID: %s\n", ssid) + return "", dbus.MakeFailedError(fmt.Errorf("no passphrase stored for network '%s'", ssid)) + } + + fmt.Printf("[Agent] Returning passphrase for SSID: %s\n", ssid) + return passphrase, nil +} + +// RequestPrivateKeyPassphrase is called for encrypted private keys +func (a *Agent) RequestPrivateKeyPassphrase(network dbus.ObjectPath) (string, *dbus.Error) { + // Not implemented for now + return "", dbus.MakeFailedError(fmt.Errorf("RequestPrivateKeyPassphrase not implemented")) +} + +// RequestUserNameAndPassword is called for enterprise networks +func (a *Agent) RequestUserNameAndPassword(network dbus.ObjectPath) (string, string, *dbus.Error) { + // Not implemented for now + return "", "", dbus.MakeFailedError(fmt.Errorf("RequestUserNameAndPassword not implemented")) +} + +// RequestUserPassword is called for enterprise networks with known username +func (a *Agent) RequestUserPassword(network dbus.ObjectPath, user string) (string, *dbus.Error) { + // Not implemented for now + return "", dbus.MakeFailedError(fmt.Errorf("RequestUserPassword not implemented")) +} + +// Cancel is called when a request is canceled +func (a *Agent) Cancel(reason string) *dbus.Error { + // Nothing to do, just acknowledge + return nil +} + +// Release is called when the agent is unregistered +func (a *Agent) Release() *dbus.Error { + // Cleanup if needed + a.mu.Lock() + a.passphraseStore = make(map[string]string) + a.mu.Unlock() + return nil +} diff --git a/internal/wifi/iwd/agentmanager.go b/internal/wifi/iwd/agentmanager.go new file mode 100644 index 0000000..7ea6c77 --- /dev/null +++ b/internal/wifi/iwd/agentmanager.go @@ -0,0 +1,39 @@ +package iwd + +import ( + "fmt" + + "github.com/godbus/dbus/v5" +) + +// AgentManager handles agent registration with iwd +type AgentManager struct { + conn *dbus.Conn + obj dbus.BusObject +} + +// NewAgentManager creates a new AgentManager instance +func NewAgentManager(conn *dbus.Conn) *AgentManager { + return &AgentManager{ + conn: conn, + obj: conn.Object(Service, "/net/connman/iwd"), + } +} + +// RegisterAgent registers an agent with iwd +func (am *AgentManager) RegisterAgent(agentPath dbus.ObjectPath) error { + err := am.obj.Call(AgentManagerInterface+".RegisterAgent", 0, agentPath).Err + if err != nil { + return fmt.Errorf("failed to register agent: %v", err) + } + return nil +} + +// UnregisterAgent unregisters an agent from iwd +func (am *AgentManager) UnregisterAgent(agentPath dbus.ObjectPath) error { + err := am.obj.Call(AgentManagerInterface+".UnregisterAgent", 0, agentPath).Err + if err != nil { + return fmt.Errorf("failed to unregister agent: %v", err) + } + return nil +} diff --git a/internal/wifi/iwd/manager.go b/internal/wifi/iwd/manager.go new file mode 100644 index 0000000..f636664 --- /dev/null +++ b/internal/wifi/iwd/manager.go @@ -0,0 +1,71 @@ +package iwd + +import ( + "fmt" + "strings" + + "github.com/godbus/dbus/v5" +) + +// Manager handles iwd object discovery via ObjectManager +type Manager struct { + conn *dbus.Conn + obj dbus.BusObject +} + +// NewManager creates a new Manager instance +func NewManager(conn *dbus.Conn) *Manager { + return &Manager{ + conn: conn, + obj: conn.Object(Service, dbus.ObjectPath(ManagerPath)), + } +} + +// GetManagedObjects returns all iwd managed objects +func (m *Manager) GetManagedObjects() (map[dbus.ObjectPath]map[string]map[string]dbus.Variant, error) { + var objects map[dbus.ObjectPath]map[string]map[string]dbus.Variant + err := m.obj.Call("org.freedesktop.DBus.ObjectManager.GetManagedObjects", 0).Store(&objects) + if err != nil { + return nil, fmt.Errorf("failed to get managed objects: %v", err) + } + return objects, nil +} + +// FindStation finds the Station object for the given interface name +func (m *Manager) FindStation(interfaceName string) (*Station, error) { + objects, err := m.GetManagedObjects() + if err != nil { + return nil, err + } + + // First, find the device with matching interface name + var devicePath dbus.ObjectPath + for path, interfaces := range objects { + if deviceProps, ok := interfaces[DeviceInterface]; ok { + if nameVariant, ok := deviceProps["Name"]; ok { + if name, ok := nameVariant.Value().(string); ok && name == interfaceName { + devicePath = path + break + } + } + } + } + + if devicePath == "" { + return nil, fmt.Errorf("device with interface '%s' not found", interfaceName) + } + + // Now find the station object under this device + // Station path is typically the same as device path or a child of it + for path, interfaces := range objects { + if _, ok := interfaces[StationInterface]; ok { + // Check if this station belongs to our device + // Station path should be the device path or start with it + if path == devicePath || strings.HasPrefix(string(path), string(devicePath)+"/") { + return NewStation(m.conn, path), nil + } + } + } + + return nil, fmt.Errorf("station for device '%s' not found", interfaceName) +} diff --git a/internal/wifi/iwd/network.go b/internal/wifi/iwd/network.go new file mode 100644 index 0000000..1563a95 --- /dev/null +++ b/internal/wifi/iwd/network.go @@ -0,0 +1,64 @@ +package iwd + +import ( + "fmt" + + "github.com/godbus/dbus/v5" +) + +// Network represents an iwd Network interface +type Network struct { + path dbus.ObjectPath + conn *dbus.Conn + obj dbus.BusObject +} + +// NewNetwork creates a new Network instance +func NewNetwork(conn *dbus.Conn, path dbus.ObjectPath) *Network { + return &Network{ + path: path, + conn: conn, + obj: conn.Object(Service, path), + } +} + +// GetProperties retrieves all network properties +func (n *Network) GetProperties() (*NetworkProperties, error) { + var props map[string]dbus.Variant + err := n.obj.Call("org.freedesktop.DBus.Properties.GetAll", 0, NetworkInterface).Store(&props) + if err != nil { + return nil, fmt.Errorf("failed to get network properties: %v", err) + } + + netProps := &NetworkProperties{} + + if nameVariant, ok := props["Name"]; ok { + if name, ok := nameVariant.Value().(string); ok { + netProps.Name = name + } + } + + if typeVariant, ok := props["Type"]; ok { + if netType, ok := typeVariant.Value().(string); ok { + netProps.Type = netType + } + } + + if connectedVariant, ok := props["Connected"]; ok { + if connected, ok := connectedVariant.Value().(bool); ok { + netProps.Connected = connected + } + } + + return netProps, nil +} + +// Connect initiates a connection to this network +// Credentials are provided via the registered agent's RequestPassphrase callback +func (n *Network) Connect() error { + err := n.obj.Call(NetworkInterface+".Connect", 0).Err + if err != nil { + return fmt.Errorf("connect failed: %v", err) + } + return nil +} diff --git a/internal/wifi/iwd/station.go b/internal/wifi/iwd/station.go new file mode 100644 index 0000000..7abed90 --- /dev/null +++ b/internal/wifi/iwd/station.go @@ -0,0 +1,137 @@ +package iwd + +import ( + "fmt" + + "github.com/godbus/dbus/v5" +) + +// Station represents an iwd Station interface +type Station struct { + path dbus.ObjectPath + conn *dbus.Conn + obj dbus.BusObject +} + +// NewStation creates a new Station instance +func NewStation(conn *dbus.Conn, path dbus.ObjectPath) *Station { + return &Station{ + path: path, + conn: conn, + obj: conn.Object(Service, path), + } +} + +// Scan triggers a network scan +func (s *Station) Scan() error { + err := s.obj.Call(StationInterface+".Scan", 0).Err + if err != nil { + return fmt.Errorf("scan failed: %v", err) + } + return nil +} + +// IsScanning checks if a scan is currently in progress +func (s *Station) IsScanning() (bool, error) { + prop, err := s.obj.GetProperty(StationInterface + ".Scanning") + if err != nil { + return false, fmt.Errorf("failed to get Scanning property: %v", err) + } + + scanning, ok := prop.Value().(bool) + if !ok { + return false, fmt.Errorf("Scanning property is not a boolean") + } + + return scanning, nil +} + +// GetOrderedNetworks returns networks sorted by signal strength +func (s *Station) GetOrderedNetworks() ([]NetworkInfo, error) { + var result []struct { + Path dbus.ObjectPath + Signal int16 + } + + err := s.obj.Call(StationInterface+".GetOrderedNetworks", 0).Store(&result) + if err != nil { + return nil, fmt.Errorf("failed to get ordered networks: %v", err) + } + + networks := make([]NetworkInfo, len(result)) + for i, r := range result { + networks[i] = NetworkInfo{ + Path: r.Path, + Signal: r.Signal, + } + } + + return networks, nil +} + +// GetState returns the current connection state +func (s *Station) GetState() (StationState, error) { + prop, err := s.obj.GetProperty(StationInterface + ".State") + if err != nil { + return "", fmt.Errorf("failed to get State property: %v", err) + } + + state, ok := prop.Value().(string) + if !ok { + return "", fmt.Errorf("State property is not a string") + } + + return StationState(state), nil +} + +// Disconnect disconnects from the current network +func (s *Station) Disconnect() error { + err := s.obj.Call(StationInterface+".Disconnect", 0).Err + if err != nil { + return fmt.Errorf("disconnect failed: %v", err) + } + return nil +} + +// GetNetwork finds and returns a Network object by SSID +func (s *Station) GetNetwork(ssid string) (*Network, error) { + networks, err := s.GetOrderedNetworks() + if err != nil { + return nil, err + } + + // Find the network with matching SSID + for _, netInfo := range networks { + network := NewNetwork(s.conn, netInfo.Path) + props, err := network.GetProperties() + if err != nil { + continue + } + + if props.Name == ssid { + return network, nil + } + } + + return nil, fmt.Errorf("network '%s' not found", ssid) +} + +// GetConnectedNetwork returns the currently connected network +func (s *Station) GetConnectedNetwork() (*Network, error) { + prop, err := s.obj.GetProperty(StationInterface + ".ConnectedNetwork") + if err != nil { + return nil, fmt.Errorf("failed to get ConnectedNetwork property: %v", err) + } + + path, ok := prop.Value().(dbus.ObjectPath) + if !ok { + return nil, fmt.Errorf("ConnectedNetwork property is not an ObjectPath") + } + + // Check if path is empty (not connected) + if path == "/" || path == "" { + return nil, fmt.Errorf("not connected to any network") + } + + return NewNetwork(s.conn, path), nil +} diff --git a/internal/wifi/iwd/types.go b/internal/wifi/iwd/types.go new file mode 100644 index 0000000..76624c4 --- /dev/null +++ b/internal/wifi/iwd/types.go @@ -0,0 +1,38 @@ +package iwd + +import "github.com/godbus/dbus/v5" + +const ( + // D-Bus service and interfaces + Service = "net.connman.iwd" + ManagerPath = "/" + DeviceInterface = "net.connman.iwd.Device" + StationInterface = "net.connman.iwd.Station" + NetworkInterface = "net.connman.iwd.Network" + AgentInterface = "net.connman.iwd.Agent" + AgentManagerInterface = "net.connman.iwd.AgentManager" +) + +// NetworkInfo represents a network with its signal strength +type NetworkInfo struct { + Path dbus.ObjectPath + Signal int16 // 100 * dBm (0 to -10000) +} + +// NetworkProperties holds network properties +type NetworkProperties struct { + Name string // SSID + Type string // "open", "wep", "psk", "8021x" + Connected bool +} + +// StationState represents the connection state +type StationState string + +const ( + StateConnected StationState = "connected" + StateDisconnected StationState = "disconnected" + StateConnecting StationState = "connecting" + StateDisconnecting StationState = "disconnecting" + StateRoaming StationState = "roaming" +) diff --git a/internal/wifi/wifi.go b/internal/wifi/wifi.go index e7cb20d..5dd6656 100644 --- a/internal/wifi/wifi.go +++ b/internal/wifi/wifi.go @@ -2,48 +2,67 @@ package wifi import ( "fmt" - "os/exec" "sort" "strings" "time" "github.com/godbus/dbus/v5" "github.com/nemunaire/repeater/internal/models" + "github.com/nemunaire/repeater/internal/wifi/iwd" ) const ( - WPA_CONF = "/etc/wpa_supplicant/wpa_supplicant.conf" - - // D-Bus constants for wpa_supplicant - WPA_SUPPLICANT_SERVICE = "fi.w1.wpa_supplicant1" - WPA_SUPPLICANT_PATH = "/fi/w1/wpa_supplicant1" - WPA_SUPPLICANT_IFACE = "fi.w1.wpa_supplicant1" - WPA_INTERFACE_IFACE = "fi.w1.wpa_supplicant1.Interface" - WPA_BSS_IFACE = "fi.w1.wpa_supplicant1.BSS" - WPA_NETWORK_IFACE = "fi.w1.wpa_supplicant1.Network" + AGENT_PATH = "/com/github/nemunaire/repeater/agent" ) var ( wlanInterface string dbusConn *dbus.Conn - wpaSupplicant dbus.BusObject + iwdManager *iwd.Manager + station *iwd.Station + agent *iwd.Agent + agentManager *iwd.AgentManager ) -// Initialize initializes the WiFi service with D-Bus connection +// Initialize initializes the WiFi service with iwd D-Bus connection func Initialize(interfaceName string) error { wlanInterface = interfaceName var err error + + // Connect to D-Bus dbusConn, err = dbus.SystemBus() if err != nil { - return fmt.Errorf("failed to connect to D-Bus: %v", err) + return fmt.Errorf("échec de connexion à D-Bus: %v", err) + } + + // Find station for interface + iwdManager = iwd.NewManager(dbusConn) + station, err = iwdManager.FindStation(interfaceName) + if err != nil { + return fmt.Errorf("impossible de trouver la station pour %s: %v", interfaceName, err) + } + + // Create and register agent for credential callbacks + agent = iwd.NewAgent(dbusConn, dbus.ObjectPath(AGENT_PATH)) + if err := agent.Export(); err != nil { + return fmt.Errorf("échec de l'export de l'agent: %v", err) + } + + agentManager = iwd.NewAgentManager(dbusConn) + if err := agentManager.RegisterAgent(dbus.ObjectPath(AGENT_PATH)); err != nil { + agent.Unexport() + return fmt.Errorf("échec de l'enregistrement de l'agent: %v", err) } - wpaSupplicant = dbusConn.Object(WPA_SUPPLICANT_SERVICE, dbus.ObjectPath(WPA_SUPPLICANT_PATH)) return nil } -// Close closes the D-Bus connection +// Close closes the D-Bus connection and unregisters the agent func Close() { + if agentManager != nil && agent != nil { + agentManager.UnregisterAgent(dbus.ObjectPath(AGENT_PATH)) + agent.Unexport() + } if dbusConn != nil { dbusConn.Close() } @@ -51,100 +70,52 @@ func Close() { // ScanNetworks scans for available WiFi networks func ScanNetworks() ([]models.WiFiNetwork, error) { - interfacePath, err := getWiFiInterfacePath() - if err != nil { - return nil, fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err) - } - - wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath) - - // Check current scanning state - scanning, err := wifiInterface.GetProperty(WPA_INTERFACE_IFACE + ".Scanning") - if err == nil && scanning.Value().(bool) { - // Scan already in progress, wait for it to complete + // Check if already scanning + scanning, err := station.IsScanning() + if err == nil && scanning { time.Sleep(3 * time.Second) } else { - // Trigger a scan - call := wifiInterface.Call(WPA_INTERFACE_IFACE+".Scan", 0, map[string]dbus.Variant{"Type": dbus.MakeVariant("active")}) - if call.Err != nil { - // If scan is rejected, it might be too soon after a previous scan - // Try to use cached results instead - if strings.Contains(call.Err.Error(), "rejected") { - // Continue to retrieve existing BSS list - } else { - return nil, fmt.Errorf("erreur lors du scan: %v", call.Err) - } - } else { - // Wait for scan to complete - time.Sleep(2 * time.Second) + // Trigger scan + err := station.Scan() + if err != nil && !strings.Contains(err.Error(), "rejected") { + return nil, fmt.Errorf("erreur lors du scan: %v", err) } + time.Sleep(2 * time.Second) } - // Retrieve BSS list - bssePaths, err := wifiInterface.GetProperty(WPA_INTERFACE_IFACE + ".BSSs") + // Get ordered networks + networkInfos, err := station.GetOrderedNetworks() if err != nil { - return nil, fmt.Errorf("erreur lors de la récupération des BSS: %v", err) + return nil, fmt.Errorf("erreur lors de la récupération des réseaux: %v", err) } var networks []models.WiFiNetwork seenSSIDs := make(map[string]bool) - for _, bssPath := range bssePaths.Value().([]dbus.ObjectPath) { - bss := dbusConn.Object(WPA_SUPPLICANT_SERVICE, bssPath) - - // Get BSS properties - var props map[string]dbus.Variant - err = bss.Call("org.freedesktop.DBus.Properties.GetAll", 0, WPA_BSS_IFACE).Store(&props) + for _, netInfo := range networkInfos { + network := iwd.NewNetwork(dbusConn, netInfo.Path) + props, err := network.GetProperties() if err != nil { continue } - network := models.WiFiNetwork{} - - // Extract SSID - if ssidBytes, ok := props["SSID"].Value().([]byte); ok { - network.SSID = string(ssidBytes) - } - - // Skip duplicates and empty SSIDs - if network.SSID == "" || seenSSIDs[network.SSID] { + if props.Name == "" || seenSSIDs[props.Name] { continue } - seenSSIDs[network.SSID] = true + seenSSIDs[props.Name] = true - // Extract BSSID - if bssidBytes, ok := props["BSSID"].Value().([]byte); ok { - network.BSSID = fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", - bssidBytes[0], bssidBytes[1], bssidBytes[2], bssidBytes[3], bssidBytes[4], bssidBytes[5]) + wifiNet := models.WiFiNetwork{ + SSID: props.Name, + Signal: signalToStrength(int(netInfo.Signal) / 100), + Security: mapSecurityType(props.Type), + BSSID: generateSyntheticBSSID(props.Name), + Channel: 0, } - // Extract signal strength - if signal, ok := props["Signal"].Value().(int16); ok { - network.Signal = signalToStrength(int(signal)) - } - - // Extract frequency and calculate channel - if frequency, ok := props["Frequency"].Value().(uint16); ok { - network.Channel = frequencyToChannel(int(frequency)) - } - - // Determine security - if privacyVal, ok := props["Privacy"].Value().(bool); ok && privacyVal { - if wpaProps, ok := props["WPA"].Value().(map[string]dbus.Variant); ok && len(wpaProps) > 0 { - network.Security = "WPA" - } else if rsnProps, ok := props["RSN"].Value().(map[string]dbus.Variant); ok && len(rsnProps) > 0 { - network.Security = "WPA2" - } else { - network.Security = "WEP" - } - } else { - network.Security = "Open" - } - - networks = append(networks, network) + networks = append(networks, wifiNet) } - // Sort by signal strength + // Sort by signal strength (descending) sort.Slice(networks, func(i, j int) bool { return networks[i].Signal > networks[j].Signal }) @@ -152,37 +123,32 @@ func ScanNetworks() ([]models.WiFiNetwork, error) { return networks, nil } -// Connect connects to a WiFi network using D-Bus +// Connect connects to a WiFi network using iwd agent callback func Connect(ssid, password string) error { - interfacePath, err := getWiFiInterfacePath() - if err != nil { - return fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err) - } - - wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath) - - // Create a new network - networkConfig := map[string]dbus.Variant{ - "ssid": dbus.MakeVariant(ssid), - } - + // Store passphrase in agent for callback if password != "" { - networkConfig["psk"] = dbus.MakeVariant(password) + agent.SetPassphrase(ssid, password) } - var networkPath dbus.ObjectPath - err = wifiInterface.Call(WPA_INTERFACE_IFACE+".AddNetwork", 0, networkConfig).Store(&networkPath) + // Ensure passphrase is cleared after connection attempt + defer func() { + if password != "" { + agent.ClearPassphrase(ssid) + } + }() + + // Get network object + network, err := station.GetNetwork(ssid) if err != nil { - return fmt.Errorf("erreur lors de l'ajout du réseau: %v", err) + return fmt.Errorf("réseau '%s' non trouvé: %v", ssid, err) } - // Select the network - err = wifiInterface.Call(WPA_INTERFACE_IFACE+".SelectNetwork", 0, networkPath).Err - if err != nil { - return fmt.Errorf("erreur lors de la sélection du réseau: %v", err) + // Connect - iwd will call agent.RequestPassphrase() if needed + if err := network.Connect(); err != nil { + return fmt.Errorf("erreur lors de la connexion: %v", err) } - // Wait for connection + // Poll for connection for i := 0; i < 20; i++ { time.Sleep(500 * time.Millisecond) if IsConnected() { @@ -195,81 +161,69 @@ func Connect(ssid, password string) error { // Disconnect disconnects from the current WiFi network func Disconnect() error { - interfacePath, err := getWiFiInterfacePath() - if err != nil { - return fmt.Errorf("impossible d'obtenir l'interface WiFi: %v", err) - } - - wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath) - - // Disconnect - err = wifiInterface.Call(WPA_INTERFACE_IFACE+".Disconnect", 0).Err - if err != nil { + if err := station.Disconnect(); err != nil { return fmt.Errorf("erreur lors de la déconnexion: %v", err) } - - // Remove all networks - var networks []dbus.ObjectPath - err = wifiInterface.Call(WPA_INTERFACE_IFACE+".Get", 0, WPA_INTERFACE_IFACE, "Networks").Store(&networks) - if err == nil { - for _, networkPath := range networks { - wifiInterface.Call(WPA_INTERFACE_IFACE+".RemoveNetwork", 0, networkPath) - } - } - return nil } -// IsConnected checks if WiFi is connected using D-Bus +// IsConnected checks if WiFi is connected using iwd func IsConnected() bool { - interfacePath, err := getWiFiInterfacePath() + state, err := station.GetState() if err != nil { return false } - - wifiInterface := dbusConn.Object(WPA_SUPPLICANT_SERVICE, interfacePath) - var state string - err = wifiInterface.Call(WPA_INTERFACE_IFACE+".Get", 0, WPA_INTERFACE_IFACE, "State").Store(&state) - if err != nil { - return false - } - - return state == "completed" + return state == iwd.StateConnected } -// IsConnectedLegacy checks if WiFi is connected using iwconfig (fallback) -func IsConnectedLegacy() bool { - cmd := exec.Command("iwconfig", wlanInterface) - output, err := cmd.Output() +// GetConnectedSSID returns the SSID of the currently connected network +func GetConnectedSSID() string { + network, err := station.GetConnectedNetwork() if err != nil { - return false + return "" } - return strings.Contains(string(output), "Access Point:") + props, err := network.GetProperties() + if err != nil { + return "" + } + + return props.Name } -// getWiFiInterfacePath retrieves the D-Bus path for the WiFi interface -func getWiFiInterfacePath() (dbus.ObjectPath, error) { - var interfacePath dbus.ObjectPath - err := wpaSupplicant.Call(WPA_SUPPLICANT_IFACE+".GetInterface", 0, wlanInterface).Store(&interfacePath) - if err != nil { - return "", fmt.Errorf("erreur lors de la récupération des interfaces: %v", err) +// mapSecurityType maps iwd security types to display format +func mapSecurityType(iwdType string) string { + switch iwdType { + case "open": + return "Open" + case "wep": + return "WEP" + case "psk": + return "WPA2" + case "8021x": + return "WPA2" + default: + return "WPA2" } - - return interfacePath, nil } -// frequencyToChannel converts WiFi frequency to channel number -func frequencyToChannel(frequency int) int { - if frequency >= 2412 && frequency <= 2484 { - if frequency == 2484 { - return 14 - } - return (frequency-2412)/5 + 1 - } else if frequency >= 5170 && frequency <= 5825 { - return (frequency - 5000) / 5 +// generateSyntheticBSSID generates a consistent fake BSSID from SSID +func generateSyntheticBSSID(ssid string) string { + // Use a simple hash approach - consistent per SSID + hash := 0 + for _, c := range ssid { + hash = ((hash << 5) - hash) + int(c) } - return 0 + + // Generate 6 bytes for MAC address + b1 := byte((hash >> 0) & 0xff) + b2 := byte((hash >> 8) & 0xff) + b3 := byte((hash >> 16) & 0xff) + b4 := byte((hash >> 24) & 0xff) + b5 := byte(len(ssid) & 0xff) + b6 := byte((len(ssid) >> 8) & 0xff) + + return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", b1, b2, b3, b4, b5, b6) } // signalToStrength converts signal level (dBm) to strength (1-5) From c443fce24f55824ca0d9413621510c7b03f4c50d Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 1 Jan 2026 17:32:46 +0700 Subject: [PATCH 06/13] Report hotspot config --- cmd/repeater/static/app.js | 118 ++++++++++++++++++------------ cmd/repeater/static/index.html | 54 ++------------ cmd/repeater/static/style.css | 33 +++++++++ internal/api/handlers/handlers.go | 11 ++- internal/app/app.go | 6 +- internal/hotspot/hotspot.go | 68 ++++++++++++++++- internal/models/models.go | 14 +++- openapi.yaml | 100 +++++++++++-------------- 8 files changed, 244 insertions(+), 160 deletions(-) diff --git a/cmd/repeater/static/app.js b/cmd/repeater/static/app.js index 3813ab8..eacedee 100644 --- a/cmd/repeater/static/app.js +++ b/cmd/repeater/static/app.js @@ -149,43 +149,6 @@ async function disconnectWifi() { } } -async function updateHotspot() { - const ssid = document.getElementById('hotspotName').value; - const password = document.getElementById('hotspotPassword').value; - const channel = parseInt(document.getElementById('hotspotChannel').value); - - if (!ssid || ssid.length > 32) { - showToast('warning', 'Attention', 'SSID invalide (1-32 caractères)'); - return; - } - - if (!password || password.length < 8 || password.length > 63) { - showToast('warning', 'Attention', 'Mot de passe invalide (8-63 caractères)'); - return; - } - - showLoading(true); - - try { - const response = await fetch('/api/hotspot/config', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ ssid, password, channel }) - }); - - if (response.ok) { - showToast('success', 'Configuration', 'Hotspot configuré avec succès'); - } else { - throw new Error('Configuration failed'); - } - } catch (error) { - console.error('Error configuring hotspot:', error); - showToast('error', 'Erreur', 'Échec de la configuration'); - } finally { - showLoading(false); - } -} - async function toggleHotspot() { const toggle = document.getElementById('hotspotToggle'); const enabled = toggle.checked; @@ -286,9 +249,12 @@ function updateStatusDisplay(status) { const hotspotText = hotspotStatus.querySelector('.status-text'); const hotspotToggle = document.getElementById('hotspotToggle'); - if (status.hotspotEnabled) { + const isHotspotEnabled = status.hotspotStatus && status.hotspotStatus.state === 'ENABLED'; + + if (isHotspotEnabled) { hotspotDot.className = 'status-dot active'; - hotspotText.textContent = 'Hotspot actif'; + const numStations = status.hotspotStatus.numStations || 0; + hotspotText.textContent = `Hotspot actif (${numStations} client${numStations > 1 ? 's' : ''})`; hotspotToggle.checked = true; } else { hotspotDot.className = 'status-dot offline'; @@ -296,7 +262,10 @@ function updateStatusDisplay(status) { hotspotToggle.checked = false; } - appState.hotspotEnabled = status.hotspotEnabled; + appState.hotspotEnabled = isHotspotEnabled; + + // Update hotspot details if available + updateHotspotDetails(status.hotspotStatus); // Check if connectedSSID changed and refresh WiFi list if needed const connectedSSIDChanged = appState.connectedSSID !== status.connectedSSID; @@ -409,11 +378,21 @@ function addLogEntry(log) { const timestamp = new Date(log.timestamp).toLocaleTimeString('fr-FR'); - logEntry.innerHTML = ` - ${timestamp} - [${escapeHtml(log.source)}] - ${escapeHtml(log.message)} - `; + const timestampSpan = document.createElement('span'); + timestampSpan.className = 'log-timestamp'; + timestampSpan.textContent = timestamp; + + const sourceSpan = document.createElement('span'); + sourceSpan.className = 'log-source'; + sourceSpan.textContent = `[${log.source}]`; + + const messageSpan = document.createElement('span'); + messageSpan.className = 'log-message'; + messageSpan.textContent = log.message; + + logEntry.appendChild(timestampSpan); + logEntry.appendChild(sourceSpan); + logEntry.appendChild(messageSpan); logContainer.appendChild(logEntry); @@ -422,6 +401,55 @@ function addLogEntry(log) { } } +function createHotspotInfoItem(label, value) { + const item = document.createElement('div'); + item.className = 'hotspot-info-item'; + + const labelSpan = document.createElement('span'); + labelSpan.className = 'info-label'; + labelSpan.textContent = label + ':'; + + const valueSpan = document.createElement('span'); + valueSpan.className = 'info-value'; + valueSpan.textContent = value; + + item.appendChild(labelSpan); + item.appendChild(valueSpan); + + return item; +} + +function updateHotspotDetails(hotspotStatus) { + const detailsContainer = document.getElementById('hotspotDetails'); + if (!detailsContainer) return; + + // Clear existing content + detailsContainer.innerHTML = ''; + + if (!hotspotStatus || hotspotStatus.state !== 'ENABLED') { + detailsContainer.appendChild(createHotspotInfoItem('État', 'Inactif')); + return; + } + + detailsContainer.appendChild(createHotspotInfoItem('État', hotspotStatus.state)); + + if (hotspotStatus.ssid) { + detailsContainer.appendChild(createHotspotInfoItem('SSID', hotspotStatus.ssid)); + } + + if (hotspotStatus.channel) { + detailsContainer.appendChild(createHotspotInfoItem('Canal', `${hotspotStatus.channel} (${hotspotStatus.frequency} MHz)`)); + } + + if (hotspotStatus.numStations !== undefined) { + detailsContainer.appendChild(createHotspotInfoItem('Clients connectés', hotspotStatus.numStations.toString())); + } + + if (hotspotStatus.bssid) { + detailsContainer.appendChild(createHotspotInfoItem('BSSID', hotspotStatus.bssid)); + } +} + // ===== WebSocket Functions ===== function connectWebSocket() { diff --git a/cmd/repeater/static/index.html b/cmd/repeater/static/index.html index 90e4be4..d53a645 100644 --- a/cmd/repeater/static/index.html +++ b/cmd/repeater/static/index.html @@ -133,7 +133,7 @@ - Configuration Hotspot + Hotspot Status
@@ -141,54 +141,12 @@
-
- - +
+
+ État: + Chargement... +
- -
- - -
- -
- - -
- -
diff --git a/cmd/repeater/static/style.css b/cmd/repeater/static/style.css index e2de87c..26464cd 100644 --- a/cmd/repeater/static/style.css +++ b/cmd/repeater/static/style.css @@ -747,6 +747,39 @@ body { background: var(--text-secondary); } +/* Hotspot Details */ +.hotspot-details { + margin-top: 1.5rem; + padding: 1rem; + background: var(--background); + border-radius: var(--radius-md); + border: 1px solid var(--border-color); +} + +.hotspot-info-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.5rem 0; + border-bottom: 1px solid var(--border-color); +} + +.hotspot-info-item:last-child { + border-bottom: none; +} + +.info-label { + font-weight: 500; + color: var(--text-secondary); + font-size: 0.875rem; +} + +.info-value { + font-weight: 600; + color: var(--text-primary); + font-size: 0.875rem; +} + /* Responsive Design */ @media (max-width: 1024px) { .grid { diff --git a/internal/api/handlers/handlers.go b/internal/api/handlers/handlers.go index 4bb2302..fefa8ef 100644 --- a/internal/api/handlers/handlers.go +++ b/internal/api/handlers/handlers.go @@ -82,11 +82,11 @@ func ConfigureHotspot(c *gin.Context) { // ToggleHotspot handles hotspot enable/disable func ToggleHotspot(c *gin.Context, status *models.SystemStatus) { - status.HotspotEnabled = !status.HotspotEnabled - enabled := status.HotspotEnabled + // Determine current state + isEnabled := status.HotspotStatus != nil && status.HotspotStatus.State == "ENABLED" var err error - if enabled { + if !isEnabled { err = hotspot.Start() logging.AddLog("Hotspot", "Hotspot activé") } else { @@ -100,7 +100,10 @@ func ToggleHotspot(c *gin.Context, status *models.SystemStatus) { return } - c.JSON(http.StatusOK, gin.H{"enabled": enabled}) + // Update status immediately + status.HotspotStatus = hotspot.GetDetailedStatus() + + c.JSON(http.StatusOK, gin.H{"enabled": !isEnabled}) } // GetDevices returns connected devices diff --git a/internal/app/app.go b/internal/app/app.go index 3924f2a..5cbf5f5 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -12,6 +12,7 @@ import ( "github.com/nemunaire/repeater/internal/api" "github.com/nemunaire/repeater/internal/config" "github.com/nemunaire/repeater/internal/device" + "github.com/nemunaire/repeater/internal/hotspot" "github.com/nemunaire/repeater/internal/logging" "github.com/nemunaire/repeater/internal/models" "github.com/nemunaire/repeater/internal/wifi" @@ -32,7 +33,7 @@ func New(assets embed.FS) *App { Status: models.SystemStatus{ Connected: false, ConnectedSSID: "", - HotspotEnabled: true, + HotspotStatus: nil, ConnectedCount: 0, DataUsage: 0.0, Uptime: 0, @@ -131,6 +132,9 @@ func (a *App) periodicStatusUpdate() { a.Status.ConnectedSSID = wifi.GetConnectedSSID() a.Status.Uptime = getSystemUptime() + // Get detailed hotspot status + a.Status.HotspotStatus = hotspot.GetDetailedStatus() + // Get network data usage for WiFi interface if a.Config != nil { rxBytes, txBytes := getInterfaceBytes(a.Config.WifiInterface) diff --git a/internal/hotspot/hotspot.go b/internal/hotspot/hotspot.go index aff0483..793352f 100644 --- a/internal/hotspot/hotspot.go +++ b/internal/hotspot/hotspot.go @@ -4,6 +4,8 @@ import ( "fmt" "os" "os/exec" + "strconv" + "strings" "github.com/nemunaire/repeater/internal/models" ) @@ -36,12 +38,74 @@ rsn_pairwise=CCMP // Start starts the hotspot func Start() error { - cmd := exec.Command("systemctl", "start", "hostapd") + cmd := exec.Command("/etc/init.d/hostapd", "start") return cmd.Run() } // Stop stops the hotspot func Stop() error { - cmd := exec.Command("systemctl", "stop", "hostapd") + cmd := exec.Command("/etc/init.d/hostapd", "stop") return cmd.Run() } + +// Status checks if the hotspot is running. +// Returns nil if the service is running, or an error if it's stopped or crashed. +func Status() error { + cmd := exec.Command("/etc/init.d/hostapd", "status") + return cmd.Run() +} + +// GetDetailedStatus retrieves detailed status information from hostapd_cli. +// Returns nil if hostapd is not running or if there's an error. +func GetDetailedStatus() *models.HotspotStatus { + cmd := exec.Command("hostapd_cli", "status") + output, err := cmd.Output() + if err != nil { + return nil + } + + status := &models.HotspotStatus{} + lines := strings.Split(string(output), "\n") + + for _, line := range lines { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "Selected interface") { + continue + } + + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + switch key { + case "state": + status.State = value + case "channel": + if ch, err := strconv.Atoi(value); err == nil { + status.Channel = ch + } + case "freq": + if freq, err := strconv.Atoi(value); err == nil { + status.Frequency = freq + } + case "ssid[0]": + status.SSID = value + case "bssid[0]": + status.BSSID = value + case "num_sta[0]": + if num, err := strconv.Atoi(value); err == nil { + status.NumStations = num + } + case "hw_mode": + status.HWMode = value + case "country_code": + status.CountryCode = value + } + } + + return status +} diff --git a/internal/models/models.go b/internal/models/models.go index d582364..b1500d1 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -26,11 +26,23 @@ type HotspotConfig struct { Channel int `json:"channel"` } +// HotspotStatus represents detailed hotspot status +type HotspotStatus struct { + State string `json:"state"` // ENABLED, DISABLED, etc. + SSID string `json:"ssid"` // Current SSID being broadcast + BSSID string `json:"bssid"` // MAC address of the AP + Channel int `json:"channel"` // Current channel + Frequency int `json:"frequency"` // Frequency in MHz + NumStations int `json:"numStations"` // Number of connected stations + HWMode string `json:"hwMode"` // Hardware mode (g, a, n, ac, etc.) + CountryCode string `json:"countryCode"` // Country code +} + // SystemStatus represents overall system status type SystemStatus struct { Connected bool `json:"connected"` ConnectedSSID string `json:"connectedSSID"` - HotspotEnabled bool `json:"hotspotEnabled"` + HotspotStatus *HotspotStatus `json:"hotspotStatus,omitempty"` // Detailed hotspot status ConnectedCount int `json:"connectedCount"` DataUsage float64 `json:"dataUsage"` Uptime int64 `json:"uptime"` diff --git a/openapi.yaml b/openapi.yaml index d1ee08e..bdcf204 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -133,42 +133,6 @@ paths: schema: $ref: '#/components/schemas/Error' - /api/hotspot/config: - post: - tags: - - Hotspot - summary: Configure hotspot settings - description: | - Updates the hotspot (access point) configuration including SSID, password, - and WiFi channel. Changes are written to hostapd configuration file. - The hotspot needs to be restarted for changes to take effect. - operationId: configureHotspot - requestBody: - required: true - content: - application/json: - schema: - $ref: '#/components/schemas/HotspotConfig' - responses: - '200': - description: Configuration updated successfully - content: - application/json: - schema: - $ref: '#/components/schemas/SuccessResponse' - '400': - description: Invalid configuration data - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - '500': - description: Configuration update failed - content: - application/json: - schema: - $ref: '#/components/schemas/Error' - /api/hotspot/toggle: post: tags: @@ -176,7 +140,8 @@ paths: summary: Toggle hotspot on/off description: | Enables or disables the hotspot (access point) by starting/stopping - the hostapd service. Returns the new enabled state. + the hostapd service. Returns the new enabled state and updates + the system status with current hostapd_cli information. operationId: toggleHotspot responses: '200': @@ -242,7 +207,8 @@ paths: summary: Get system status description: | Returns comprehensive system status including WiFi connection state, - hotspot status, connected device count, data usage, and uptime. + detailed hotspot status from hostapd_cli, connected device count, + data usage, and uptime. operationId: getStatus responses: '200': @@ -367,32 +333,48 @@ components: - ssid - password - HotspotConfig: + HotspotStatus: type: object - description: Hotspot (access point) configuration + description: Detailed hotspot status from hostapd_cli properties: + state: + type: string + description: Hotspot state (ENABLED, DISABLED, etc.) + example: "ENABLED" ssid: type: string - description: Hotspot SSID (network name) - minLength: 1 - maxLength: 32 + description: Current SSID being broadcast example: "TravelRouter" - password: + bssid: type: string - description: WPA2 password (minimum 8 characters) - minLength: 8 - maxLength: 63 - example: "secure123" + description: MAC address of the access point + pattern: '^([0-9a-fA-F]{2}:){5}[0-9a-fA-F]{2}$' + example: "4a:e3:4e:09:57:f8" channel: type: integer - description: WiFi channel (1-11 for 2.4GHz) + description: Current WiFi channel minimum: 1 maximum: 14 - example: 6 + example: 11 + frequency: + type: integer + description: Frequency in MHz + example: 2462 + numStations: + type: integer + description: Number of connected stations + minimum: 0 + example: 2 + hwMode: + type: string + description: Hardware mode (g, a, n, ac, etc.) + example: "g" + countryCode: + type: string + description: Country code + example: "VN" required: - - ssid - - password - - channel + - state ConnectedDevice: type: object @@ -440,10 +422,11 @@ components: type: string description: SSID of connected upstream network (empty if not connected) example: "Hotel-Guest" - hotspotEnabled: - type: boolean - description: Whether the hotspot is currently enabled - example: true + hotspotStatus: + allOf: + - $ref: '#/components/schemas/HotspotStatus' + nullable: true + description: Detailed hotspot status (null if hotspot is not running) connectedCount: type: integer description: Number of devices connected to hotspot @@ -452,7 +435,7 @@ components: dataUsage: type: number format: double - description: Total data usage in MB (placeholder for future implementation) + description: Total data usage in MB example: 145.7 uptime: type: integer @@ -467,7 +450,6 @@ components: required: - connected - connectedSSID - - hotspotEnabled - connectedCount - dataUsage - uptime From 1477d909b0389b689e78bc81dc2bb4c3ba3abc03 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 1 Jan 2026 17:42:53 +0700 Subject: [PATCH 07/13] Add websocket wifi updates --- cmd/repeater/static/app.js | 123 ++++++++++++- internal/api/handlers/handlers.go | 12 ++ internal/api/handlers/websocket_wifi.go | 30 ++++ internal/api/router.go | 4 +- internal/app/app.go | 7 + internal/wifi/broadcaster.go | 140 +++++++++++++++ internal/wifi/events.go | 70 ++++++++ internal/wifi/iwd/signals.go | 223 ++++++++++++++++++++++++ internal/wifi/iwd/station.go | 5 + internal/wifi/wifi.go | 115 +++++++++++- openapi.yaml | 36 ++++ 11 files changed, 755 insertions(+), 10 deletions(-) create mode 100644 internal/api/handlers/websocket_wifi.go create mode 100644 internal/wifi/broadcaster.go create mode 100644 internal/wifi/events.go create mode 100644 internal/wifi/iwd/signals.go diff --git a/cmd/repeater/static/app.js b/cmd/repeater/static/app.js index eacedee..3bb7f82 100644 --- a/cmd/repeater/static/app.js +++ b/cmd/repeater/static/app.js @@ -4,7 +4,9 @@ const appState = { hotspotEnabled: true, autoScrollLogs: true, ws: null, + wifiWs: null, reconnectAttempts: 0, + wifiReconnectAttempts: 0, maxReconnectAttempts: 5, connectedSSID: null, networks: [], @@ -28,8 +30,9 @@ async function initializeApp() { loadLogs() ]); - // Set up WebSocket for real-time logs + // Set up WebSockets for real-time updates connectWebSocket(); + connectWifiWebSocket(); // Start periodic updates startPeriodicUpdates(); @@ -72,8 +75,20 @@ async function scanWifi() { showToast('success', 'Scan WiFi', `${networks.length} réseau(x) trouvé(s)`); } catch (error) { console.error('Error scanning WiFi:', error); - wifiList.innerHTML = '
Erreur lors du scan
'; - showToast('error', 'Erreur', 'Échec du scan WiFi'); + + // Fallback to cached networks + try { + const fallbackResponse = await fetch('/api/wifi/networks'); + const cachedNetworks = await fallbackResponse.json(); + + appState.networks = cachedNetworks; + displayWifiNetworks(cachedNetworks); + showToast('warning', 'Scan échoué', `Affichage des réseaux en cache (${cachedNetworks.length})`); + } catch (fallbackError) { + console.error('Error loading cached networks:', fallbackError); + wifiList.innerHTML = '
Erreur lors du scan
'; + showToast('error', 'Erreur', 'Échec du scan WiFi'); + } } finally { if (scanBtn) { scanBtn.disabled = false; @@ -491,6 +506,108 @@ function connectWebSocket() { } } +function connectWifiWebSocket() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/ws/wifi`; + + try { + appState.wifiWs = new WebSocket(wsUrl); + + appState.wifiWs.onopen = function() { + console.log('WiFi WebSocket connected'); + appState.wifiReconnectAttempts = 0; + }; + + appState.wifiWs.onmessage = function(event) { + try { + const msg = JSON.parse(event.data); + handleWifiEvent(msg); + } catch (error) { + console.error('Error parsing WiFi WebSocket message:', error); + } + }; + + appState.wifiWs.onerror = function(error) { + console.error('WiFi WebSocket error:', error); + }; + + appState.wifiWs.onclose = function() { + console.log('WiFi WebSocket disconnected'); + + // Attempt to reconnect + if (appState.wifiReconnectAttempts < appState.maxReconnectAttempts) { + appState.wifiReconnectAttempts++; + setTimeout(connectWifiWebSocket, 5000); + } + }; + } catch (error) { + console.error('Error creating WiFi WebSocket:', error); + } +} + +function handleWifiEvent(event) { + console.log('WiFi event received:', event.type, event); + + switch (event.type) { + case 'scan_update': + handleScanUpdate(event.data); + break; + case 'state_change': + handleStateChange(event.data); + break; + case 'signal_update': + handleSignalUpdate(event.data); + break; + default: + console.warn('Unknown WiFi event type:', event.type); + } +} + +function handleScanUpdate(data) { + // Update the network list in real-time + appState.networks = data.networks; + displayWifiNetworks(data.networks); + console.log(`Scan update: ${data.networks.length} network(s) found`); +} + +function handleStateChange(data) { + // Update WiFi status badge + const wifiStatus = document.getElementById('wifiStatus'); + const wifiDot = wifiStatus.querySelector('.status-dot'); + const wifiText = wifiStatus.querySelector('.status-text'); + + if (data.state === 'connected') { + wifiDot.className = 'status-dot active'; + wifiText.textContent = `Connecté: ${data.ssid}`; + appState.connectedSSID = data.ssid; + + // Refresh network list to show connected network + if (appState.networks.length > 0) { + displayWifiNetworks(appState.networks); + } + } else if (data.state === 'disconnected') { + wifiDot.className = 'status-dot offline'; + wifiText.textContent = 'Déconnecté'; + appState.connectedSSID = null; + + // Refresh network list to remove connected highlighting + if (appState.networks.length > 0) { + displayWifiNetworks(appState.networks); + } + } else if (data.state === 'connecting') { + wifiDot.className = 'status-dot'; + wifiText.textContent = `Connexion à ${data.ssid}...`; + } + + console.log(`WiFi state changed: ${data.previous_state} → ${data.state}`, data.ssid); +} + +function handleSignalUpdate(data) { + // Update signal strength display if needed + console.log(`Signal update for ${data.ssid}: ${data.signal}/5 (${data.dbm} dBm)`); + // Could update the network list to reflect new signal strength +} + // ===== Utility Functions ===== function generateSignalBars(strength) { diff --git a/internal/api/handlers/handlers.go b/internal/api/handlers/handlers.go index fefa8ef..6ef8ec1 100644 --- a/internal/api/handlers/handlers.go +++ b/internal/api/handlers/handlers.go @@ -12,6 +12,18 @@ import ( "github.com/nemunaire/repeater/internal/wifi" ) +// GetWiFiNetworks returns cached WiFi networks without scanning +func GetWiFiNetworks(c *gin.Context) { + networks, err := wifi.GetCachedNetworks() + if err != nil { + logging.AddLog("WiFi", "Erreur lors de la récupération des réseaux: "+err.Error()) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la récupération des réseaux"}) + return + } + + c.JSON(http.StatusOK, networks) +} + // ScanWiFi handles WiFi network scanning func ScanWiFi(c *gin.Context) { networks, err := wifi.ScanNetworks() diff --git a/internal/api/handlers/websocket_wifi.go b/internal/api/handlers/websocket_wifi.go new file mode 100644 index 0000000..ff8d132 --- /dev/null +++ b/internal/api/handlers/websocket_wifi.go @@ -0,0 +1,30 @@ +package handlers + +import ( + "log" + + "github.com/gin-gonic/gin" + "github.com/nemunaire/repeater/internal/wifi" +) + +// WebSocketWifi handles WebSocket connections for real-time WiFi events +func WebSocketWifi(c *gin.Context) { + conn, err := upgrader.Upgrade(c.Writer, c.Request, nil) + if err != nil { + log.Printf("Erreur WebSocket WiFi: %v", err) + return + } + defer conn.Close() + + // Register client + wifi.RegisterWebSocketClient(conn) + defer wifi.UnregisterWebSocketClient(conn) + + // Keep connection alive + for { + _, _, err := conn.ReadMessage() + if err != nil { + break + } + } +} diff --git a/internal/api/router.go b/internal/api/router.go index a06c328..956661e 100644 --- a/internal/api/router.go +++ b/internal/api/router.go @@ -24,6 +24,7 @@ func SetupRouter(status *models.SystemStatus, cfg *config.Config, assets embed.F // WiFi endpoints wifi := api.Group("/wifi") { + wifi.GET("/networks", handlers.GetWiFiNetworks) wifi.GET("/scan", handlers.ScanWiFi) wifi.POST("/connect", handlers.ConnectWiFi) wifi.POST("/disconnect", handlers.DisconnectWiFi) @@ -53,8 +54,9 @@ func SetupRouter(status *models.SystemStatus, cfg *config.Config, assets embed.F api.DELETE("/logs", handlers.ClearLogs) } - // WebSocket endpoint + // WebSocket endpoints r.GET("/ws/logs", handlers.WebSocketLogs) + r.GET("/ws/wifi", handlers.WebSocketWifi) // Serve static files sub, err := fs.Sub(assets, "static") diff --git a/internal/app/app.go b/internal/app/app.go index 5cbf5f5..de556da 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -53,6 +53,12 @@ func (a *App) Initialize(cfg *config.Config) error { return err } + // Start WiFi event monitoring + if err := wifi.StartEventMonitoring(); err != nil { + log.Printf("Warning: WiFi event monitoring failed: %v", err) + // Don't fail - polling fallback still works + } + // Start periodic tasks go a.periodicStatusUpdate() go a.periodicDeviceUpdate() @@ -71,6 +77,7 @@ func (a *App) Run(addr string) error { // Shutdown gracefully shuts down the application func (a *App) Shutdown() { + wifi.StopEventMonitoring() wifi.Close() logging.AddLog("Système", "Application arrêtée") } diff --git a/internal/wifi/broadcaster.go b/internal/wifi/broadcaster.go new file mode 100644 index 0000000..931ad3e --- /dev/null +++ b/internal/wifi/broadcaster.go @@ -0,0 +1,140 @@ +package wifi + +import ( + "log" + "sync" + + "github.com/gorilla/websocket" + "github.com/nemunaire/repeater/internal/models" +) + +// WifiBroadcaster manages WebSocket clients and broadcasts WiFi events +type WifiBroadcaster struct { + clients map[*websocket.Conn]bool + clientsMu sync.RWMutex + + // State deduplication + lastState string + lastSSID string + lastNetworks []models.WiFiNetwork + stateMu sync.RWMutex +} + +// NewWifiBroadcaster creates a new WiFi broadcaster +func NewWifiBroadcaster() *WifiBroadcaster { + return &WifiBroadcaster{ + clients: make(map[*websocket.Conn]bool), + } +} + +// RegisterClient registers a new WebSocket client +func (wb *WifiBroadcaster) RegisterClient(conn *websocket.Conn) { + wb.clientsMu.Lock() + wb.clients[conn] = true + wb.clientsMu.Unlock() + + // Send initial state to the new client + wb.sendInitialState(conn) +} + +// UnregisterClient removes a WebSocket client +func (wb *WifiBroadcaster) UnregisterClient(conn *websocket.Conn) { + wb.clientsMu.Lock() + delete(wb.clients, conn) + wb.clientsMu.Unlock() +} + +// sendInitialState sends the current WiFi state to a newly connected client +func (wb *WifiBroadcaster) sendInitialState(conn *websocket.Conn) { + wb.stateMu.RLock() + lastState := wb.lastState + lastSSID := wb.lastSSID + lastNetworks := make([]models.WiFiNetwork, len(wb.lastNetworks)) + copy(lastNetworks, wb.lastNetworks) + wb.stateMu.RUnlock() + + // Send last known state if available + if lastState != "" { + event := NewStateChangeEvent(lastState, lastSSID, "") + conn.WriteJSON(event) + } + + // Send last known network list if available + if len(lastNetworks) > 0 { + event := NewScanUpdateEvent(lastNetworks) + conn.WriteJSON(event) + } +} + +// BroadcastScanUpdate broadcasts a scan update event to all clients +func (wb *WifiBroadcaster) BroadcastScanUpdate(networks []models.WiFiNetwork) { + // Check for changes to avoid duplicate broadcasts + wb.stateMu.Lock() + if networksEqual(wb.lastNetworks, networks) { + wb.stateMu.Unlock() + return + } + wb.lastNetworks = make([]models.WiFiNetwork, len(networks)) + copy(wb.lastNetworks, networks) + wb.stateMu.Unlock() + + event := NewScanUpdateEvent(networks) + wb.broadcast(event) +} + +// BroadcastStateChange broadcasts a state change event to all clients +func (wb *WifiBroadcaster) BroadcastStateChange(state, ssid string) { + // Check for changes to avoid duplicate broadcasts + wb.stateMu.Lock() + if wb.lastState == state && wb.lastSSID == ssid { + wb.stateMu.Unlock() + return + } + previousState := wb.lastState + wb.lastState = state + wb.lastSSID = ssid + wb.stateMu.Unlock() + + event := NewStateChangeEvent(state, ssid, previousState) + wb.broadcast(event) +} + +// BroadcastSignalUpdate broadcasts a signal update event to all clients +func (wb *WifiBroadcaster) BroadcastSignalUpdate(ssid string, signal, dbm int) { + event := NewSignalUpdateEvent(ssid, signal, dbm) + wb.broadcast(event) +} + +// broadcast sends an event to all connected clients +func (wb *WifiBroadcaster) broadcast(event WifiEvent) { + // Get list of clients with read lock + wb.clientsMu.RLock() + clients := make([]*websocket.Conn, 0, len(wb.clients)) + for client := range wb.clients { + clients = append(clients, client) + } + wb.clientsMu.RUnlock() + + // Broadcast to all clients + for _, client := range clients { + err := client.WriteJSON(event) + if err != nil { + log.Printf("Erreur lors de l'envoi WebSocket WiFi: %v", err) + client.Close() + wb.UnregisterClient(client) + } + } +} + +// networksEqual compares two network slices for equality +func networksEqual(a, b []models.WiFiNetwork) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i].SSID != b[i].SSID || a[i].Signal != b[i].Signal { + return false + } + } + return true +} diff --git a/internal/wifi/events.go b/internal/wifi/events.go new file mode 100644 index 0000000..c8f3672 --- /dev/null +++ b/internal/wifi/events.go @@ -0,0 +1,70 @@ +package wifi + +import ( + "time" + + "github.com/nemunaire/repeater/internal/models" +) + +// WifiEvent represents a WiFi event to be sent over WebSocket +type WifiEvent struct { + Type string `json:"type"` + Timestamp time.Time `json:"timestamp"` + Data interface{} `json:"data"` +} + +// ScanUpdateData contains network list update information +type ScanUpdateData struct { + Networks []models.WiFiNetwork `json:"networks"` +} + +// StateChangeData contains connection state change information +type StateChangeData struct { + State string `json:"state"` + SSID string `json:"ssid,omitempty"` + PreviousState string `json:"previous_state,omitempty"` +} + +// SignalUpdateData contains signal strength update information +type SignalUpdateData struct { + SSID string `json:"ssid"` + Signal int `json:"signal"` // 1-5 scale + DBm int `json:"dbm"` // Raw dBm value +} + +// NewScanUpdateEvent creates a new scan update event +func NewScanUpdateEvent(networks []models.WiFiNetwork) WifiEvent { + return WifiEvent{ + Type: "scan_update", + Timestamp: time.Now(), + Data: ScanUpdateData{ + Networks: networks, + }, + } +} + +// NewStateChangeEvent creates a new state change event +func NewStateChangeEvent(state, ssid, previousState string) WifiEvent { + return WifiEvent{ + Type: "state_change", + Timestamp: time.Now(), + Data: StateChangeData{ + State: state, + SSID: ssid, + PreviousState: previousState, + }, + } +} + +// NewSignalUpdateEvent creates a new signal update event +func NewSignalUpdateEvent(ssid string, signal, dbm int) WifiEvent { + return WifiEvent{ + Type: "signal_update", + Timestamp: time.Now(), + Data: SignalUpdateData{ + SSID: ssid, + Signal: signal, + DBm: dbm, + }, + } +} diff --git a/internal/wifi/iwd/signals.go b/internal/wifi/iwd/signals.go new file mode 100644 index 0000000..90ed32e --- /dev/null +++ b/internal/wifi/iwd/signals.go @@ -0,0 +1,223 @@ +package iwd + +import ( + "log" + "sync" + + "github.com/godbus/dbus/v5" +) + +// SignalMonitor monitors D-Bus signals from iwd +type SignalMonitor struct { + conn *dbus.Conn + station *Station + + // Signal channel + signalChan chan *dbus.Signal + + // Callbacks + onStateChange func(state StationState, ssid string) + onScanComplete func() + + // Control + stopChan chan struct{} + mu sync.RWMutex + running bool + + // State tracking + lastScanning bool +} + +// NewSignalMonitor creates a new signal monitor +func NewSignalMonitor(conn *dbus.Conn, station *Station) *SignalMonitor { + return &SignalMonitor{ + conn: conn, + station: station, + signalChan: make(chan *dbus.Signal, 100), + stopChan: make(chan struct{}), + } +} + +// OnStateChange registers a callback for state changes +func (sm *SignalMonitor) OnStateChange(callback func(state StationState, ssid string)) { + sm.mu.Lock() + defer sm.mu.Unlock() + sm.onStateChange = callback +} + +// OnScanComplete registers a callback for scan completion +func (sm *SignalMonitor) OnScanComplete(callback func()) { + sm.mu.Lock() + defer sm.mu.Unlock() + sm.onScanComplete = callback +} + +// Start begins monitoring D-Bus signals +func (sm *SignalMonitor) Start() error { + sm.mu.Lock() + if sm.running { + sm.mu.Unlock() + return nil + } + sm.running = true + sm.mu.Unlock() + + // Subscribe to PropertiesChanged signals for Station interface + stationPath := sm.station.GetPath() + + // Add signal match for PropertiesChanged on Station interface + matchOptions := []dbus.MatchOption{ + dbus.WithMatchObjectPath(stationPath), + dbus.WithMatchInterface("org.freedesktop.DBus.Properties"), + dbus.WithMatchMember("PropertiesChanged"), + } + + if err := sm.conn.AddMatchSignal(matchOptions...); err != nil { + sm.mu.Lock() + sm.running = false + sm.mu.Unlock() + return err + } + + // Register signal channel + sm.conn.Signal(sm.signalChan) + + // Get initial scanning state + scanning, err := sm.station.IsScanning() + if err == nil { + sm.lastScanning = scanning + } + + // Start monitoring goroutine + go sm.monitor() + + log.Printf("D-Bus signal monitoring started for station %s", stationPath) + return nil +} + +// Stop stops monitoring D-Bus signals +func (sm *SignalMonitor) Stop() { + sm.mu.Lock() + if !sm.running { + sm.mu.Unlock() + return + } + sm.running = false + sm.mu.Unlock() + + // Signal stop + close(sm.stopChan) + + // Remove signal channel + sm.conn.RemoveSignal(sm.signalChan) + + log.Printf("D-Bus signal monitoring stopped") +} + +// monitor is the main signal processing loop +func (sm *SignalMonitor) monitor() { + for { + select { + case sig := <-sm.signalChan: + sm.handleSignal(sig) + case <-sm.stopChan: + return + } + } +} + +// handleSignal processes a D-Bus signal +func (sm *SignalMonitor) handleSignal(sig *dbus.Signal) { + // Only process PropertiesChanged signals + if sig.Name != "org.freedesktop.DBus.Properties.PropertiesChanged" { + return + } + + // Verify signal is from Station interface + if len(sig.Body) < 2 { + return + } + + interfaceName, ok := sig.Body[0].(string) + if !ok || interfaceName != StationInterface { + return + } + + // Parse changed properties + changedProps, ok := sig.Body[1].(map[string]dbus.Variant) + if !ok { + return + } + + // Check for State property change + if stateVariant, ok := changedProps["State"]; ok { + if state, ok := stateVariant.Value().(string); ok { + sm.handleStateChange(StationState(state)) + } + } + + // Check for Scanning property change + if scanningVariant, ok := changedProps["Scanning"]; ok { + if scanning, ok := scanningVariant.Value().(bool); ok { + sm.handleScanningChange(scanning) + } + } + + // Check for ConnectedNetwork property change + if _, ok := changedProps["ConnectedNetwork"]; ok { + // Network connection changed, trigger state update + sm.handleConnectionChange() + } +} + +// handleStateChange processes a state change +func (sm *SignalMonitor) handleStateChange(state StationState) { + sm.mu.RLock() + callback := sm.onStateChange + sm.mu.RUnlock() + + if callback == nil { + return + } + + // Get connected SSID if connected + ssid := "" + if state == StateConnected { + network, err := sm.station.GetConnectedNetwork() + if err == nil { + props, err := network.GetProperties() + if err == nil { + ssid = props.Name + } + } + } + + callback(state, ssid) +} + +// handleScanningChange processes scanning state changes +func (sm *SignalMonitor) handleScanningChange(scanning bool) { + // Detect scan completion (transition from true to false) + if sm.lastScanning && !scanning { + sm.mu.RLock() + callback := sm.onScanComplete + sm.mu.RUnlock() + + if callback != nil { + callback() + } + } + + sm.lastScanning = scanning +} + +// handleConnectionChange processes connection changes +func (sm *SignalMonitor) handleConnectionChange() { + // Get current state and trigger state change callback + state, err := sm.station.GetState() + if err != nil { + return + } + + sm.handleStateChange(state) +} diff --git a/internal/wifi/iwd/station.go b/internal/wifi/iwd/station.go index 7abed90..54014fd 100644 --- a/internal/wifi/iwd/station.go +++ b/internal/wifi/iwd/station.go @@ -135,3 +135,8 @@ func (s *Station) GetConnectedNetwork() (*Network, error) { return NewNetwork(s.conn, path), nil } + +// GetPath returns the D-Bus object path for this station +func (s *Station) GetPath() dbus.ObjectPath { + return s.path +} diff --git a/internal/wifi/wifi.go b/internal/wifi/wifi.go index 5dd6656..b242e2a 100644 --- a/internal/wifi/wifi.go +++ b/internal/wifi/wifi.go @@ -7,6 +7,7 @@ import ( "time" "github.com/godbus/dbus/v5" + "github.com/gorilla/websocket" "github.com/nemunaire/repeater/internal/models" "github.com/nemunaire/repeater/internal/wifi/iwd" ) @@ -16,12 +17,14 @@ const ( ) var ( - wlanInterface string - dbusConn *dbus.Conn - iwdManager *iwd.Manager - station *iwd.Station - agent *iwd.Agent - agentManager *iwd.AgentManager + wlanInterface string + dbusConn *dbus.Conn + iwdManager *iwd.Manager + station *iwd.Station + agent *iwd.Agent + agentManager *iwd.AgentManager + eventMonitor *iwd.SignalMonitor + wifiBroadcaster *WifiBroadcaster ) // Initialize initializes the WiFi service with iwd D-Bus connection @@ -68,6 +71,48 @@ func Close() { } } +// GetCachedNetworks returns previously discovered networks without triggering a scan +func GetCachedNetworks() ([]models.WiFiNetwork, error) { + // Get ordered networks without scanning + networkInfos, err := station.GetOrderedNetworks() + if err != nil { + return nil, fmt.Errorf("erreur lors de la récupération des réseaux: %v", err) + } + + var networks []models.WiFiNetwork + seenSSIDs := make(map[string]bool) + + for _, netInfo := range networkInfos { + network := iwd.NewNetwork(dbusConn, netInfo.Path) + props, err := network.GetProperties() + if err != nil { + continue + } + + if props.Name == "" || seenSSIDs[props.Name] { + continue + } + seenSSIDs[props.Name] = true + + wifiNet := models.WiFiNetwork{ + SSID: props.Name, + Signal: signalToStrength(int(netInfo.Signal) / 100), + Security: mapSecurityType(props.Type), + BSSID: generateSyntheticBSSID(props.Name), + Channel: 0, + } + + networks = append(networks, wifiNet) + } + + // Sort by signal strength (descending) + sort.Slice(networks, func(i, j int) bool { + return networks[i].Signal > networks[j].Signal + }) + + return networks, nil +} + // ScanNetworks scans for available WiFi networks func ScanNetworks() ([]models.WiFiNetwork, error) { // Check if already scanning @@ -120,6 +165,11 @@ func ScanNetworks() ([]models.WiFiNetwork, error) { return networks[i].Signal > networks[j].Signal }) + // Broadcast to WebSocket clients if available + if wifiBroadcaster != nil { + wifiBroadcaster.BroadcastScanUpdate(networks) + } + return networks, nil } @@ -191,6 +241,59 @@ func GetConnectedSSID() string { return props.Name } +// StartEventMonitoring initializes D-Bus signal monitoring and WebSocket broadcasting +func StartEventMonitoring() error { + // Initialize broadcaster + wifiBroadcaster = NewWifiBroadcaster() + + // Create signal monitor + eventMonitor = iwd.NewSignalMonitor(dbusConn, station) + + // Register callbacks + eventMonitor.OnStateChange(handleStateChange) + eventMonitor.OnScanComplete(handleScanComplete) + + // Start monitoring + return eventMonitor.Start() +} + +// StopEventMonitoring stops D-Bus signal monitoring +func StopEventMonitoring() { + if eventMonitor != nil { + eventMonitor.Stop() + } +} + +// RegisterWebSocketClient registers a new WebSocket client for WiFi events +func RegisterWebSocketClient(conn *websocket.Conn) { + if wifiBroadcaster != nil { + wifiBroadcaster.RegisterClient(conn) + } +} + +// UnregisterWebSocketClient removes a WebSocket client +func UnregisterWebSocketClient(conn *websocket.Conn) { + if wifiBroadcaster != nil { + wifiBroadcaster.UnregisterClient(conn) + } +} + +// handleStateChange is called when WiFi connection state changes +func handleStateChange(newState iwd.StationState, connectedSSID string) { + if wifiBroadcaster != nil { + wifiBroadcaster.BroadcastStateChange(string(newState), connectedSSID) + } +} + +// handleScanComplete is called when a WiFi scan completes +func handleScanComplete() { + // Get updated network list + networks, err := GetCachedNetworks() + if err == nil && wifiBroadcaster != nil { + wifiBroadcaster.BroadcastScanUpdate(networks) + } +} + // mapSecurityType maps iwd security types to display format func mapSecurityType(iwdType string) string { switch iwdType { diff --git a/openapi.yaml b/openapi.yaml index bdcf204..c357310 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -28,6 +28,42 @@ tags: description: System logs and real-time monitoring paths: + /api/wifi/networks: + get: + tags: + - WiFi + summary: Get discovered WiFi networks + description: | + Returns the list of WiFi networks from the last scan without triggering a new scan. + Returns an empty list if no scan has been performed yet. + operationId: getWiFiNetworks + responses: + '200': + description: List of discovered networks + content: + application/json: + schema: + type: array + items: + $ref: '#/components/schemas/WiFiNetwork' + example: + - ssid: "Hotel-Guest" + signal: 5 + security: "WPA2" + channel: 6 + bssid: "aa:bb:cc:dd:ee:ff" + - ssid: "Public-WiFi" + signal: 3 + security: "Open" + channel: 11 + bssid: "11:22:33:44:55:66" + '500': + description: Error retrieving networks + content: + application/json: + schema: + $ref: '#/components/schemas/Error' + /api/wifi/scan: get: tags: From 02b93a3ef0d93243f46b1d9cd18fcb741e4ca303 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 1 Jan 2026 17:58:06 +0700 Subject: [PATCH 08/13] Handle connecting/disconnecting states --- cmd/repeater/static/app.js | 138 +++++++++++++++++++++++++--------- cmd/repeater/static/style.css | 42 +++++++++++ internal/app/app.go | 14 ++-- internal/models/models.go | 1 + internal/wifi/wifi.go | 9 +++ openapi.yaml | 11 +++ 6 files changed, 175 insertions(+), 40 deletions(-) diff --git a/cmd/repeater/static/app.js b/cmd/repeater/static/app.js index 3bb7f82..a7c367d 100644 --- a/cmd/repeater/static/app.js +++ b/cmd/repeater/static/app.js @@ -9,6 +9,7 @@ const appState = { wifiReconnectAttempts: 0, maxReconnectAttempts: 5, connectedSSID: null, + connectionState: 'disconnected', networks: [], uptime: 0, uptimeInterval: null @@ -104,10 +105,11 @@ async function connectToWifi() { const password = document.getElementById('wifiPassword').value; - if (appState.selectedWifi.security !== 'Open' && !password) { - showToast('warning', 'Attention', 'Mot de passe requis pour ce réseau'); - return; - } + // Password requirement disabled + // if (appState.selectedWifi.security !== 'Open' && !password) { + // showToast('warning', 'Attention', 'Mot de passe requis pour ce réseau'); + // return; + // } showLoading(true); @@ -250,12 +252,32 @@ function updateStatusDisplay(status) { const wifiDot = wifiStatus.querySelector('.status-dot'); const wifiText = wifiStatus.querySelector('.status-text'); - if (status.connected) { - wifiDot.className = 'status-dot active'; - wifiText.textContent = `Connecté: ${status.connectedSSID}`; - } else { - wifiDot.className = 'status-dot offline'; - wifiText.textContent = 'Déconnecté'; + // Use connectionState for more detailed status + const state = status.connectionState || (status.connected ? 'connected' : 'disconnected'); + appState.connectionState = state; + + switch (state) { + case 'connected': + wifiDot.className = 'status-dot active'; + wifiText.textContent = `Connecté: ${status.connectedSSID}`; + break; + case 'connecting': + wifiDot.className = 'status-dot connecting'; + wifiText.textContent = status.connectedSSID ? `Connexion à ${status.connectedSSID}...` : 'Connexion...'; + break; + case 'disconnecting': + wifiDot.className = 'status-dot disconnecting'; + wifiText.textContent = 'Déconnexion...'; + break; + case 'roaming': + wifiDot.className = 'status-dot active'; + wifiText.textContent = `Roaming: ${status.connectedSSID}`; + break; + case 'disconnected': + default: + wifiDot.className = 'status-dot offline'; + wifiText.textContent = 'Déconnecté'; + break; } // Update hotspot status badge @@ -282,11 +304,14 @@ function updateStatusDisplay(status) { // Update hotspot details if available updateHotspotDetails(status.hotspotStatus); - // Check if connectedSSID changed and refresh WiFi list if needed - const connectedSSIDChanged = appState.connectedSSID !== status.connectedSSID; + // Check if connectedSSID or state changed and refresh WiFi list if needed + const prevSSID = appState.connectedSSID; + const prevState = appState.connectionState; appState.connectedSSID = status.connectedSSID; - if (connectedSSIDChanged && appState.networks.length > 0) { + const connectedChanged = prevSSID !== status.connectedSSID || prevState !== state; + + if (connectedChanged && appState.networks.length > 0) { displayWifiNetworks(appState.networks); } @@ -314,9 +339,20 @@ function displayWifiNetworks(networks) { const wifiItem = document.createElement('div'); wifiItem.className = 'wifi-item'; - // Mark the currently connected network + // Mark the network based on connection state if (appState.connectedSSID && network.ssid === appState.connectedSSID) { - wifiItem.classList.add('connected'); + switch (appState.connectionState) { + case 'connected': + case 'roaming': + wifiItem.classList.add('connected'); + break; + case 'connecting': + wifiItem.classList.add('connecting'); + break; + case 'disconnecting': + wifiItem.classList.add('disconnecting'); + break; + } } wifiItem.onclick = () => selectWifi(network, wifiItem); @@ -576,27 +612,61 @@ function handleStateChange(data) { const wifiDot = wifiStatus.querySelector('.status-dot'); const wifiText = wifiStatus.querySelector('.status-text'); - if (data.state === 'connected') { - wifiDot.className = 'status-dot active'; - wifiText.textContent = `Connecté: ${data.ssid}`; - appState.connectedSSID = data.ssid; + // Update state in appState + appState.connectionState = data.state; - // Refresh network list to show connected network - if (appState.networks.length > 0) { - displayWifiNetworks(appState.networks); - } - } else if (data.state === 'disconnected') { - wifiDot.className = 'status-dot offline'; - wifiText.textContent = 'Déconnecté'; - appState.connectedSSID = null; + switch (data.state) { + case 'connected': + wifiDot.className = 'status-dot active'; + wifiText.textContent = `Connecté: ${data.ssid}`; + appState.connectedSSID = data.ssid; - // Refresh network list to remove connected highlighting - if (appState.networks.length > 0) { - displayWifiNetworks(appState.networks); - } - } else if (data.state === 'connecting') { - wifiDot.className = 'status-dot'; - wifiText.textContent = `Connexion à ${data.ssid}...`; + // Refresh network list to show connected network + if (appState.networks.length > 0) { + displayWifiNetworks(appState.networks); + } + break; + + case 'connecting': + wifiDot.className = 'status-dot connecting'; + wifiText.textContent = data.ssid ? `Connexion à ${data.ssid}...` : 'Connexion...'; + + // Refresh network list to show connecting state + if (appState.networks.length > 0) { + displayWifiNetworks(appState.networks); + } + break; + + case 'disconnecting': + wifiDot.className = 'status-dot disconnecting'; + wifiText.textContent = 'Déconnexion...'; + + // Refresh network list to show disconnecting state + if (appState.networks.length > 0) { + displayWifiNetworks(appState.networks); + } + break; + + case 'roaming': + wifiDot.className = 'status-dot active'; + wifiText.textContent = `Roaming: ${data.ssid}`; + appState.connectedSSID = data.ssid; + break; + + case 'disconnected': + wifiDot.className = 'status-dot offline'; + wifiText.textContent = 'Déconnecté'; + appState.connectedSSID = null; + + // Refresh network list to remove connected highlighting + if (appState.networks.length > 0) { + displayWifiNetworks(appState.networks); + } + break; + + default: + console.warn('Unknown WiFi state:', data.state); + break; } console.log(`WiFi state changed: ${data.previous_state} → ${data.state}`, data.ssid); diff --git a/cmd/repeater/static/style.css b/cmd/repeater/static/style.css index 26464cd..b518696 100644 --- a/cmd/repeater/static/style.css +++ b/cmd/repeater/static/style.css @@ -107,11 +107,26 @@ body { animation: none; } +.status-dot.connecting { + background: var(--warning-color); + animation: blink 1s ease-in-out infinite; +} + +.status-dot.disconnecting { + background: var(--warning-color); + animation: blink 1s ease-in-out infinite; +} + @keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: 0.5; } } +@keyframes blink { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.3; } +} + /* Stats Grid */ .stats-grid { display: grid; @@ -428,6 +443,33 @@ body { font-weight: 700; } +.wifi-item.connecting { + background: #fef3c7; + border-left-color: var(--warning-color) !important; + animation: pulse-item 1.5s ease-in-out infinite; +} + +.wifi-item.connecting .wifi-ssid { + color: var(--warning-color); + font-weight: 700; +} + +.wifi-item.disconnecting { + background: #fee2e2; + border-left-color: #dc2626 !important; + animation: pulse-item 1.5s ease-in-out infinite; +} + +.wifi-item.disconnecting .wifi-ssid { + color: #dc2626; + font-weight: 700; +} + +@keyframes pulse-item { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.7; } +} + .wifi-item.loading { justify-content: center; color: var(--text-secondary); diff --git a/internal/app/app.go b/internal/app/app.go index de556da..d8e792b 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -31,12 +31,13 @@ type App struct { func New(assets embed.FS) *App { return &App{ Status: models.SystemStatus{ - Connected: false, - ConnectedSSID: "", - HotspotStatus: nil, - ConnectedCount: 0, - DataUsage: 0.0, - Uptime: 0, + Connected: false, + ConnectionState: "disconnected", + ConnectedSSID: "", + HotspotStatus: nil, + ConnectedCount: 0, + DataUsage: 0.0, + Uptime: 0, }, StartTime: time.Now(), Assets: assets, @@ -136,6 +137,7 @@ func (a *App) periodicStatusUpdate() { for range ticker.C { a.StatusMutex.Lock() a.Status.Connected = wifi.IsConnected() + a.Status.ConnectionState = wifi.GetConnectionState() a.Status.ConnectedSSID = wifi.GetConnectedSSID() a.Status.Uptime = getSystemUptime() diff --git a/internal/models/models.go b/internal/models/models.go index b1500d1..64d5c79 100644 --- a/internal/models/models.go +++ b/internal/models/models.go @@ -41,6 +41,7 @@ type HotspotStatus struct { // SystemStatus represents overall system status type SystemStatus struct { Connected bool `json:"connected"` + ConnectionState string `json:"connectionState"` // Connection state: connected, disconnected, connecting, disconnecting, roaming ConnectedSSID string `json:"connectedSSID"` HotspotStatus *HotspotStatus `json:"hotspotStatus,omitempty"` // Detailed hotspot status ConnectedCount int `json:"connectedCount"` diff --git a/internal/wifi/wifi.go b/internal/wifi/wifi.go index b242e2a..ee678c1 100644 --- a/internal/wifi/wifi.go +++ b/internal/wifi/wifi.go @@ -241,6 +241,15 @@ func GetConnectedSSID() string { return props.Name } +// GetConnectionState returns the current WiFi connection state +func GetConnectionState() string { + state, err := station.GetState() + if err != nil { + return string(iwd.StateDisconnected) + } + return string(state) +} + // StartEventMonitoring initializes D-Bus signal monitoring and WebSocket broadcasting func StartEventMonitoring() error { // Initialize broadcaster diff --git a/openapi.yaml b/openapi.yaml index c357310..4357d09 100644 --- a/openapi.yaml +++ b/openapi.yaml @@ -454,6 +454,16 @@ components: type: boolean description: Whether router is connected to upstream WiFi example: true + connectionState: + type: string + description: Current WiFi connection state + enum: + - connected + - disconnected + - connecting + - disconnecting + - roaming + example: "connected" connectedSSID: type: string description: SSID of connected upstream network (empty if not connected) @@ -485,6 +495,7 @@ components: $ref: '#/components/schemas/ConnectedDevice' required: - connected + - connectionState - connectedSSID - connectedCount - dataUsage From f4481bca629a04665108fbe51222a9b581dd0179 Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 1 Jan 2026 21:43:50 +0700 Subject: [PATCH 09/13] Stream logs from syslog --- internal/app/app.go | 30 +++++- internal/config/cli.go | 4 + internal/config/config.go | 8 ++ internal/syslog/parser.go | 32 ++++++ internal/syslog/syslog.go | 218 ++++++++++++++++++++++++++++++++++++++ 5 files changed, 287 insertions(+), 5 deletions(-) create mode 100644 internal/syslog/parser.go create mode 100644 internal/syslog/syslog.go diff --git a/internal/app/app.go b/internal/app/app.go index d8e792b..b07f246 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -15,16 +15,18 @@ import ( "github.com/nemunaire/repeater/internal/hotspot" "github.com/nemunaire/repeater/internal/logging" "github.com/nemunaire/repeater/internal/models" + "github.com/nemunaire/repeater/internal/syslog" "github.com/nemunaire/repeater/internal/wifi" ) // App represents the application type App struct { - Status models.SystemStatus - StatusMutex sync.RWMutex - StartTime time.Time - Assets embed.FS - Config *config.Config + Status models.SystemStatus + StatusMutex sync.RWMutex + StartTime time.Time + Assets embed.FS + Config *config.Config + SyslogTailer *syslog.SyslogTailer } // New creates a new application instance @@ -60,6 +62,19 @@ func (a *App) Initialize(cfg *config.Config) error { // Don't fail - polling fallback still works } + // Start syslog tailing if enabled + if cfg.SyslogEnabled { + a.SyslogTailer = syslog.NewSyslogTailer( + cfg.SyslogPath, + cfg.SyslogFilter, + cfg.SyslogSource, + ) + if err := a.SyslogTailer.Start(); err != nil { + log.Printf("Warning: Failed to start syslog tailing: %v", err) + // Don't fail - app continues without syslog + } + } + // Start periodic tasks go a.periodicStatusUpdate() go a.periodicDeviceUpdate() @@ -78,6 +93,11 @@ func (a *App) Run(addr string) error { // Shutdown gracefully shuts down the application func (a *App) Shutdown() { + // Stop syslog tailing if running + if a.SyslogTailer != nil { + a.SyslogTailer.Stop() + } + wifi.StopEventMonitoring() wifi.Close() logging.AddLog("Système", "Application arrêtée") diff --git a/internal/config/cli.go b/internal/config/cli.go index f61db13..9907f0c 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -11,6 +11,10 @@ func declareFlags(o *Config) { flag.BoolVar(&o.UseARPDiscovery, "use-arp-discovery", true, "Use ARP table for device discovery instead of DHCP leases") flag.StringVar(&o.DHCPLeasesPath, "dhcp-leases-path", "/var/lib/dhcp/dhcpd.leases", "Path to DHCP leases file") flag.StringVar(&o.ARPTablePath, "arp-table-path", "/proc/net/arp", "Path to ARP table file") + flag.BoolVar(&o.SyslogEnabled, "syslog-enabled", false, "Enable syslog tailing for iwd messages") + flag.StringVar(&o.SyslogPath, "syslog-path", "/var/log/messages", "Path to syslog file") + flag.StringVar(&o.SyslogFilter, "syslog-filter", "daemon.info iwd:", "Filter string for syslog lines") + flag.StringVar(&o.SyslogSource, "syslog-source", "iwd", "Source name for syslog entries in logs") } // parseCLI parse the flags and treats extra args as configuration filename. diff --git a/internal/config/config.go b/internal/config/config.go index 329354b..d4a60fb 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -14,6 +14,10 @@ type Config struct { UseARPDiscovery bool DHCPLeasesPath string ARPTablePath string + SyslogEnabled bool + SyslogPath string + SyslogFilter string + SyslogSource string } // ConsolidateConfig fills an Options struct by reading configuration from @@ -28,6 +32,10 @@ func ConsolidateConfig() (opts *Config, err error) { UseARPDiscovery: true, DHCPLeasesPath: "/var/lib/dhcp/dhcpd.leases", ARPTablePath: "/proc/net/arp", + SyslogEnabled: false, + SyslogPath: "/var/log/messages", + SyslogFilter: "daemon.info iwd:", + SyslogSource: "iwd", } declareFlags(opts) diff --git a/internal/syslog/parser.go b/internal/syslog/parser.go new file mode 100644 index 0000000..10c07b6 --- /dev/null +++ b/internal/syslog/parser.go @@ -0,0 +1,32 @@ +package syslog + +import ( + "strings" +) + +// ParseSyslogLine extracts the message content from a syslog line. +// It looks for the daemon prefix in the line and returns the message after it. +// +// Example input: "Dec 2 02:01:33 tyet daemon.info iwd: Error loading /var/lib/iwd//nemuphone.psk" +// Example output: "Error loading /var/lib/iwd//nemuphone.psk", true +// +// Returns the message and a boolean indicating if the line was successfully parsed. +func ParseSyslogLine(line, daemonPrefix string) (string, bool) { + // Find the daemon prefix in the line (e.g., "iwd:") + idx := strings.Index(line, daemonPrefix) + if idx == -1 { + return "", false + } + + // Extract everything after the daemon prefix + message := line[idx+len(daemonPrefix):] + + // Trim leading/trailing whitespace + message = strings.TrimSpace(message) + + if message == "" { + return "", false + } + + return message, true +} diff --git a/internal/syslog/syslog.go b/internal/syslog/syslog.go new file mode 100644 index 0000000..35aa352 --- /dev/null +++ b/internal/syslog/syslog.go @@ -0,0 +1,218 @@ +package syslog + +import ( + "bufio" + "io" + "log" + "os" + "strings" + "sync" + "time" + + "github.com/nemunaire/repeater/internal/logging" +) + +// SyslogTailer tails a syslog file and filters messages to the logging system. +type SyslogTailer struct { + path string + filter string + source string + + file *os.File + done chan struct{} + wg sync.WaitGroup + mu sync.Mutex + running bool +} + +// NewSyslogTailer creates a new syslog tailer. +// path: Path to the syslog file (e.g., "/var/log/messages") +// filter: Filter string to match in lines (e.g., "daemon.info iwd:") +// source: Source name for logging (e.g., "iwd") +func NewSyslogTailer(path, filter, source string) *SyslogTailer { + return &SyslogTailer{ + path: path, + filter: filter, + source: source, + done: make(chan struct{}), + } +} + +// Start opens the syslog file and begins tailing it. +func (t *SyslogTailer) Start() error { + t.mu.Lock() + defer t.mu.Unlock() + + if t.running { + return nil + } + + // Try to open the file + file, err := os.Open(t.path) + if err != nil { + // File might not exist yet, we'll retry in the goroutine + log.Printf("Warning: Cannot open syslog file %s: %v (will retry)", t.path, err) + } else { + // Seek to the end to only read new entries + _, err = file.Seek(0, io.SeekEnd) + if err != nil { + file.Close() + return err + } + t.file = file + } + + t.running = true + t.wg.Add(1) + go t.tail() + + return nil +} + +// Stop signals the tailer to stop and waits for it to finish. +func (t *SyslogTailer) Stop() { + t.mu.Lock() + if !t.running { + t.mu.Unlock() + return + } + t.mu.Unlock() + + close(t.done) + t.wg.Wait() + + t.mu.Lock() + if t.file != nil { + t.file.Close() + t.file = nil + } + t.running = false + t.mu.Unlock() +} + +// tail is the main loop that reads from the syslog file. +func (t *SyslogTailer) tail() { + defer t.wg.Done() + + retryDelay := 1 * time.Second + maxRetryDelay := 30 * time.Second + + for { + select { + case <-t.done: + return + default: + } + + // Check if we have a file open + t.mu.Lock() + file := t.file + t.mu.Unlock() + + if file == nil { + // Try to open the file + newFile, err := os.Open(t.path) + if err != nil { + // File doesn't exist or can't be opened, wait and retry + select { + case <-t.done: + return + case <-time.After(retryDelay): + // Exponential backoff + retryDelay *= 2 + if retryDelay > maxRetryDelay { + retryDelay = maxRetryDelay + } + continue + } + } + + // Seek to the end + _, err = newFile.Seek(0, io.SeekEnd) + if err != nil { + log.Printf("Error seeking syslog file: %v", err) + newFile.Close() + time.Sleep(retryDelay) + continue + } + + t.mu.Lock() + t.file = newFile + file = newFile + t.mu.Unlock() + + retryDelay = 1 * time.Second + log.Printf("Syslog tailer: opened %s", t.path) + } + + // Read lines from the file + if err := t.readLines(file); err != nil { + if err == io.EOF { + // End of file, wait a bit and try again + time.Sleep(100 * time.Millisecond) + continue + } + + // Other error, close the file and retry + log.Printf("Error reading syslog file: %v", err) + t.mu.Lock() + if t.file != nil { + t.file.Close() + t.file = nil + } + t.mu.Unlock() + + time.Sleep(retryDelay) + } + } +} + +// readLines reads and processes lines from the file. +func (t *SyslogTailer) readLines(file *os.File) error { + scanner := bufio.NewScanner(file) + + // Increase buffer size to handle long log lines + const maxCapacity = 512 * 1024 + buf := make([]byte, maxCapacity) + scanner.Buffer(buf, maxCapacity) + + for scanner.Scan() { + select { + case <-t.done: + return nil + default: + } + + line := scanner.Text() + + // Check if the line contains the filter string + if !strings.Contains(line, t.filter) { + continue + } + + // Parse the syslog line to extract the message + // We look for "iwd:" (or whatever comes after the filter) + // The filter is "daemon.info iwd:" so we want to extract text after "iwd:" + daemonPrefix := extractDaemonPrefix(t.filter) + message, ok := ParseSyslogLine(line, daemonPrefix) + if !ok { + // Couldn't parse the line, skip it + continue + } + + // Add to logging system + logging.AddLog(t.source, message) + } + + return scanner.Err() +} + +// extractDaemonPrefix extracts the daemon prefix from the filter string. +// For example, "daemon.info iwd:" returns "iwd:" +func extractDaemonPrefix(filter string) string { + parts := strings.Fields(filter) + if len(parts) > 0 { + return parts[len(parts)-1] + } + return filter +} From 79c28da9c56da320a536254f10519d7965f66c2e Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 1 Jan 2026 22:13:18 +0700 Subject: [PATCH 10/13] Create wifi backend abstraction --- internal/app/app.go | 4 +- internal/config/cli.go | 1 + internal/config/config.go | 6 + internal/wifi/backend/types.go | 56 ++++++++ internal/wifi/factory.go | 21 +++ internal/wifi/iwd/backend.go | 255 +++++++++++++++++++++++++++++++++ internal/wifi/wifi.go | 232 ++++++++---------------------- 7 files changed, 401 insertions(+), 174 deletions(-) create mode 100644 internal/wifi/backend/types.go create mode 100644 internal/wifi/factory.go create mode 100644 internal/wifi/iwd/backend.go diff --git a/internal/app/app.go b/internal/app/app.go index b07f246..27299df 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -51,8 +51,8 @@ func (a *App) Initialize(cfg *config.Config) error { // Store config reference a.Config = cfg - // Initialize WiFi D-Bus connection - if err := wifi.Initialize(cfg.WifiInterface); err != nil { + // Initialize WiFi backend + if err := wifi.Initialize(cfg.WifiInterface, cfg.WifiBackend); err != nil { return err } diff --git a/internal/config/cli.go b/internal/config/cli.go index 9907f0c..a975875 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -8,6 +8,7 @@ import ( func declareFlags(o *Config) { flag.StringVar(&o.Bind, "bind", ":8081", "Bind port/socket") flag.StringVar(&o.WifiInterface, "wifi-interface", "wlan0", "WiFi interface name") + flag.StringVar(&o.WifiBackend, "wifi-backend", "", "WiFi backend to use: 'iwd' or 'wpasupplicant' (required)") flag.BoolVar(&o.UseARPDiscovery, "use-arp-discovery", true, "Use ARP table for device discovery instead of DHCP leases") flag.StringVar(&o.DHCPLeasesPath, "dhcp-leases-path", "/var/lib/dhcp/dhcpd.leases", "Path to DHCP leases file") flag.StringVar(&o.ARPTablePath, "arp-table-path", "/proc/net/arp", "Path to ARP table file") diff --git a/internal/config/config.go b/internal/config/config.go index d4a60fb..89f1d9f 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -11,6 +11,7 @@ import ( type Config struct { Bind string WifiInterface string + WifiBackend string UseARPDiscovery bool DHCPLeasesPath string ARPTablePath string @@ -75,6 +76,11 @@ func ConsolidateConfig() (opts *Config, err error) { return } + // Validate configuration + if opts.WifiBackend != "iwd" && opts.WifiBackend != "wpasupplicant" { + log.Fatalf("wifi-backend must be set to 'iwd' or 'wpasupplicant' (got: '%s')", opts.WifiBackend) + } + return } diff --git a/internal/wifi/backend/types.go b/internal/wifi/backend/types.go new file mode 100644 index 0000000..1346e9b --- /dev/null +++ b/internal/wifi/backend/types.go @@ -0,0 +1,56 @@ +package backend + +// WiFiBackend is the interface that must be implemented by all WiFi backends (iwd, wpa_supplicant, etc.) +type WiFiBackend interface { + // Lifecycle Management + Initialize(interfaceName string) error + Close() error + + // Network Discovery + ScanNetworks() error + GetOrderedNetworks() ([]BackendNetwork, error) + IsScanning() (bool, error) + + // Connection Management + Connect(ssid, password string) error + Disconnect() error + GetConnectionState() (ConnectionState, error) + GetConnectedSSID() string + + // Event Monitoring + StartEventMonitoring(callbacks EventCallbacks) error + StopEventMonitoring() +} + +// BackendNetwork represents a WiFi network in a backend-agnostic format. +// Both iwd and wpa_supplicant backends convert their native representations to this type. +type BackendNetwork struct { + SSID string + SignalDBm int16 // Signal strength in dBm (-100 to 0) + SecurityType string // "open", "wep", "psk", "8021x" + BSSID string // MAC address of the access point + Frequency uint32 // Frequency in MHz (e.g., 2412 for channel 1, 5180 for channel 36) +} + +// ConnectionState represents the WiFi connection state in a backend-agnostic way. +type ConnectionState string + +const ( + StateConnected ConnectionState = "connected" + StateDisconnected ConnectionState = "disconnected" + StateConnecting ConnectionState = "connecting" + StateDisconnecting ConnectionState = "disconnecting" +) + +// EventCallbacks defines callback functions that backends use to notify the wifi package of events. +// This allows the wifi package to remain backend-agnostic while still receiving real-time updates. +type EventCallbacks struct { + // OnStateChange is called when the connection state changes + OnStateChange func(state ConnectionState, ssid string) + + // OnScanComplete is called when a network scan completes + OnScanComplete func() + + // OnSignalUpdate is called when signal strength changes for the connected network + OnSignalUpdate func(ssid string, signalDBm int16) +} diff --git a/internal/wifi/factory.go b/internal/wifi/factory.go new file mode 100644 index 0000000..5b49c7a --- /dev/null +++ b/internal/wifi/factory.go @@ -0,0 +1,21 @@ +package wifi + +import ( + "fmt" + + "github.com/nemunaire/repeater/internal/wifi/backend" + "github.com/nemunaire/repeater/internal/wifi/iwd" +) + +// createBackend creates the appropriate WiFi backend based on the backend name +func createBackend(backendName string) (backend.WiFiBackend, error) { + switch backendName { + case "iwd": + return iwd.NewIWDBackend(), nil + case "wpasupplicant": + // TODO: Implement wpa_supplicant backend + return nil, fmt.Errorf("wpa_supplicant backend not yet implemented") + default: + return nil, fmt.Errorf("invalid wifi backend: %s (must be 'iwd' or 'wpasupplicant')", backendName) + } +} diff --git a/internal/wifi/iwd/backend.go b/internal/wifi/iwd/backend.go new file mode 100644 index 0000000..615a280 --- /dev/null +++ b/internal/wifi/iwd/backend.go @@ -0,0 +1,255 @@ +package iwd + +import ( + "fmt" + + "github.com/godbus/dbus/v5" + "github.com/nemunaire/repeater/internal/wifi/backend" +) + +const ( + AgentPath = "/com/github/nemunaire/repeater/agent" +) + +// IWDBackend implements the WiFiBackend interface for iwd (Intel Wireless Daemon) +type IWDBackend struct { + conn *dbus.Conn + manager *Manager + station *Station + agent *Agent + agentManager *AgentManager + signalMonitor *SignalMonitor + interfaceName string + callbacks backend.EventCallbacks +} + +// NewIWDBackend creates a new IWD backend instance +func NewIWDBackend() *IWDBackend { + return &IWDBackend{} +} + +// Initialize initializes the iwd backend with the given interface name +func (b *IWDBackend) Initialize(interfaceName string) error { + b.interfaceName = interfaceName + var err error + + // Connect to D-Bus + b.conn, err = dbus.SystemBus() + if err != nil { + return fmt.Errorf("échec de connexion à D-Bus: %v", err) + } + + // Find station for interface + b.manager = NewManager(b.conn) + b.station, err = b.manager.FindStation(interfaceName) + if err != nil { + return fmt.Errorf("impossible de trouver la station pour %s: %v", interfaceName, err) + } + + // Create and register agent for credential callbacks + b.agent = NewAgent(b.conn, dbus.ObjectPath(AgentPath)) + if err := b.agent.Export(); err != nil { + return fmt.Errorf("échec de l'export de l'agent: %v", err) + } + + b.agentManager = NewAgentManager(b.conn) + if err := b.agentManager.RegisterAgent(dbus.ObjectPath(AgentPath)); err != nil { + b.agent.Unexport() + return fmt.Errorf("échec de l'enregistrement de l'agent: %v", err) + } + + return nil +} + +// Close closes the D-Bus connection and unregisters the agent +func (b *IWDBackend) Close() error { + if b.agentManager != nil && b.agent != nil { + b.agentManager.UnregisterAgent(dbus.ObjectPath(AgentPath)) + b.agent.Unexport() + } + if b.conn != nil { + b.conn.Close() + } + return nil +} + +// ScanNetworks triggers a network scan +func (b *IWDBackend) ScanNetworks() error { + err := b.station.Scan() + if err != nil { + return fmt.Errorf("erreur lors du scan: %v", err) + } + return nil +} + +// GetOrderedNetworks returns networks sorted by signal strength in backend-agnostic format +func (b *IWDBackend) GetOrderedNetworks() ([]backend.BackendNetwork, error) { + networkInfos, err := b.station.GetOrderedNetworks() + if err != nil { + return nil, fmt.Errorf("erreur lors de la récupération des réseaux: %v", err) + } + + var networks []backend.BackendNetwork + seenSSIDs := make(map[string]bool) + + for _, netInfo := range networkInfos { + network := NewNetwork(b.conn, netInfo.Path) + props, err := network.GetProperties() + if err != nil { + continue + } + + if props.Name == "" || seenSSIDs[props.Name] { + continue + } + seenSSIDs[props.Name] = true + + // Convert iwd network to backend-agnostic format + backendNet := backend.BackendNetwork{ + SSID: props.Name, + SignalDBm: netInfo.Signal / 100, // iwd provides 100*dBm, convert to dBm + SecurityType: props.Type, + BSSID: generateSyntheticBSSID(props.Name), // iwd doesn't expose BSSID + Frequency: 0, // iwd doesn't expose frequency in GetOrderedNetworks + } + + networks = append(networks, backendNet) + } + + return networks, nil +} + +// IsScanning checks if a scan is currently in progress +func (b *IWDBackend) IsScanning() (bool, error) { + return b.station.IsScanning() +} + +// Connect connects to a WiFi network +func (b *IWDBackend) Connect(ssid, password string) error { + // Store passphrase in agent for callback + if password != "" { + b.agent.SetPassphrase(ssid, password) + } + + // Ensure passphrase is cleared after connection attempt + defer func() { + if password != "" { + b.agent.ClearPassphrase(ssid) + } + }() + + // Get network object + network, err := b.station.GetNetwork(ssid) + if err != nil { + return fmt.Errorf("réseau '%s' non trouvé: %v", ssid, err) + } + + // Connect - iwd will call agent.RequestPassphrase() if needed + if err := network.Connect(); err != nil { + return fmt.Errorf("erreur lors de la connexion: %v", err) + } + + return nil +} + +// Disconnect disconnects from the current WiFi network +func (b *IWDBackend) Disconnect() error { + if err := b.station.Disconnect(); err != nil { + return fmt.Errorf("erreur lors de la déconnexion: %v", err) + } + return nil +} + +// GetConnectionState returns the current WiFi connection state +func (b *IWDBackend) GetConnectionState() (backend.ConnectionState, error) { + state, err := b.station.GetState() + if err != nil { + return backend.StateDisconnected, err + } + return mapIWDState(state), nil +} + +// GetConnectedSSID returns the SSID of the currently connected network +func (b *IWDBackend) GetConnectedSSID() string { + network, err := b.station.GetConnectedNetwork() + if err != nil { + return "" + } + + props, err := network.GetProperties() + if err != nil { + return "" + } + + return props.Name +} + +// StartEventMonitoring starts monitoring WiFi events +func (b *IWDBackend) StartEventMonitoring(callbacks backend.EventCallbacks) error { + b.callbacks = callbacks + + // Create signal monitor + b.signalMonitor = NewSignalMonitor(b.conn, b.station) + + // Register callbacks - wrap to convert iwd types to backend types + b.signalMonitor.OnStateChange(func(state StationState, ssid string) { + if b.callbacks.OnStateChange != nil { + b.callbacks.OnStateChange(mapIWDState(state), ssid) + } + }) + + b.signalMonitor.OnScanComplete(func() { + if b.callbacks.OnScanComplete != nil { + b.callbacks.OnScanComplete() + } + }) + + // Start monitoring + return b.signalMonitor.Start() +} + +// StopEventMonitoring stops monitoring WiFi events +func (b *IWDBackend) StopEventMonitoring() { + if b.signalMonitor != nil { + b.signalMonitor.Stop() + } +} + +// mapIWDState maps iwd-specific states to backend-agnostic states +func mapIWDState(state StationState) backend.ConnectionState { + switch state { + case StateConnected: + return backend.StateConnected + case StateConnecting: + return backend.StateConnecting + case StateDisconnecting: + return backend.StateDisconnecting + case StateDisconnected: + return backend.StateDisconnected + case StateRoaming: + // Map roaming to connected since we're still connected during roaming + return backend.StateConnected + default: + return backend.StateDisconnected + } +} + +// generateSyntheticBSSID generates a consistent fake BSSID from SSID +// (iwd doesn't expose real BSSID) +func generateSyntheticBSSID(ssid string) string { + // Use a simple hash approach - consistent per SSID + hash := 0 + for _, c := range ssid { + hash = ((hash << 5) - hash) + int(c) + } + + // Generate 6 bytes for MAC address + b1 := byte((hash >> 0) & 0xff) + b2 := byte((hash >> 8) & 0xff) + b3 := byte((hash >> 16) & 0xff) + b4 := byte((hash >> 24) & 0xff) + b5 := byte(len(ssid) & 0xff) + b6 := byte((len(ssid) >> 8) & 0xff) + + return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", b1, b2, b3, b4, b5, b6) +} diff --git a/internal/wifi/wifi.go b/internal/wifi/wifi.go index ee678c1..dac04c3 100644 --- a/internal/wifi/wifi.go +++ b/internal/wifi/wifi.go @@ -6,102 +6,54 @@ import ( "strings" "time" - "github.com/godbus/dbus/v5" "github.com/gorilla/websocket" "github.com/nemunaire/repeater/internal/models" - "github.com/nemunaire/repeater/internal/wifi/iwd" -) - -const ( - AGENT_PATH = "/com/github/nemunaire/repeater/agent" + "github.com/nemunaire/repeater/internal/wifi/backend" ) var ( - wlanInterface string - dbusConn *dbus.Conn - iwdManager *iwd.Manager - station *iwd.Station - agent *iwd.Agent - agentManager *iwd.AgentManager - eventMonitor *iwd.SignalMonitor + wifiBackend backend.WiFiBackend wifiBroadcaster *WifiBroadcaster ) -// Initialize initializes the WiFi service with iwd D-Bus connection -func Initialize(interfaceName string) error { - wlanInterface = interfaceName +// Initialize initializes the WiFi service with the specified backend +func Initialize(interfaceName string, backendName string) error { + // Create the appropriate backend using the factory var err error - - // Connect to D-Bus - dbusConn, err = dbus.SystemBus() + wifiBackend, err = createBackend(backendName) if err != nil { - return fmt.Errorf("échec de connexion à D-Bus: %v", err) + return err } - // Find station for interface - iwdManager = iwd.NewManager(dbusConn) - station, err = iwdManager.FindStation(interfaceName) - if err != nil { - return fmt.Errorf("impossible de trouver la station pour %s: %v", interfaceName, err) - } - - // Create and register agent for credential callbacks - agent = iwd.NewAgent(dbusConn, dbus.ObjectPath(AGENT_PATH)) - if err := agent.Export(); err != nil { - return fmt.Errorf("échec de l'export de l'agent: %v", err) - } - - agentManager = iwd.NewAgentManager(dbusConn) - if err := agentManager.RegisterAgent(dbus.ObjectPath(AGENT_PATH)); err != nil { - agent.Unexport() - return fmt.Errorf("échec de l'enregistrement de l'agent: %v", err) - } - - return nil + // Initialize the backend + return wifiBackend.Initialize(interfaceName) } -// Close closes the D-Bus connection and unregisters the agent +// Close closes the backend connection func Close() { - if agentManager != nil && agent != nil { - agentManager.UnregisterAgent(dbus.ObjectPath(AGENT_PATH)) - agent.Unexport() - } - if dbusConn != nil { - dbusConn.Close() + if wifiBackend != nil { + wifiBackend.Close() } } // GetCachedNetworks returns previously discovered networks without triggering a scan func GetCachedNetworks() ([]models.WiFiNetwork, error) { - // Get ordered networks without scanning - networkInfos, err := station.GetOrderedNetworks() + // Get ordered networks from backend + backendNetworks, err := wifiBackend.GetOrderedNetworks() if err != nil { return nil, fmt.Errorf("erreur lors de la récupération des réseaux: %v", err) } - var networks []models.WiFiNetwork - seenSSIDs := make(map[string]bool) - - for _, netInfo := range networkInfos { - network := iwd.NewNetwork(dbusConn, netInfo.Path) - props, err := network.GetProperties() - if err != nil { - continue - } - - if props.Name == "" || seenSSIDs[props.Name] { - continue - } - seenSSIDs[props.Name] = true - + // Convert backend networks to models + networks := make([]models.WiFiNetwork, 0, len(backendNetworks)) + for _, backendNet := range backendNetworks { wifiNet := models.WiFiNetwork{ - SSID: props.Name, - Signal: signalToStrength(int(netInfo.Signal) / 100), - Security: mapSecurityType(props.Type), - BSSID: generateSyntheticBSSID(props.Name), - Channel: 0, + SSID: backendNet.SSID, + Signal: signalToStrength(int(backendNet.SignalDBm)), + Security: mapSecurityType(backendNet.SecurityType), + BSSID: backendNet.BSSID, + Channel: 0, // Not yet exposed by backends } - networks = append(networks, wifiNet) } @@ -116,47 +68,34 @@ func GetCachedNetworks() ([]models.WiFiNetwork, error) { // ScanNetworks scans for available WiFi networks func ScanNetworks() ([]models.WiFiNetwork, error) { // Check if already scanning - scanning, err := station.IsScanning() + scanning, err := wifiBackend.IsScanning() if err == nil && scanning { time.Sleep(3 * time.Second) } else { // Trigger scan - err := station.Scan() + err := wifiBackend.ScanNetworks() if err != nil && !strings.Contains(err.Error(), "rejected") { return nil, fmt.Errorf("erreur lors du scan: %v", err) } time.Sleep(2 * time.Second) } - // Get ordered networks - networkInfos, err := station.GetOrderedNetworks() + // Get ordered networks from backend + backendNetworks, err := wifiBackend.GetOrderedNetworks() if err != nil { return nil, fmt.Errorf("erreur lors de la récupération des réseaux: %v", err) } - var networks []models.WiFiNetwork - seenSSIDs := make(map[string]bool) - - for _, netInfo := range networkInfos { - network := iwd.NewNetwork(dbusConn, netInfo.Path) - props, err := network.GetProperties() - if err != nil { - continue - } - - if props.Name == "" || seenSSIDs[props.Name] { - continue - } - seenSSIDs[props.Name] = true - + // Convert backend networks to models + networks := make([]models.WiFiNetwork, 0, len(backendNetworks)) + for _, backendNet := range backendNetworks { wifiNet := models.WiFiNetwork{ - SSID: props.Name, - Signal: signalToStrength(int(netInfo.Signal) / 100), - Security: mapSecurityType(props.Type), - BSSID: generateSyntheticBSSID(props.Name), - Channel: 0, + SSID: backendNet.SSID, + Signal: signalToStrength(int(backendNet.SignalDBm)), + Security: mapSecurityType(backendNet.SecurityType), + BSSID: backendNet.BSSID, + Channel: 0, // Not yet exposed by backends } - networks = append(networks, wifiNet) } @@ -173,29 +112,11 @@ func ScanNetworks() ([]models.WiFiNetwork, error) { return networks, nil } -// Connect connects to a WiFi network using iwd agent callback +// Connect connects to a WiFi network func Connect(ssid, password string) error { - // Store passphrase in agent for callback - if password != "" { - agent.SetPassphrase(ssid, password) - } - - // Ensure passphrase is cleared after connection attempt - defer func() { - if password != "" { - agent.ClearPassphrase(ssid) - } - }() - - // Get network object - network, err := station.GetNetwork(ssid) - if err != nil { - return fmt.Errorf("réseau '%s' non trouvé: %v", ssid, err) - } - - // Connect - iwd will call agent.RequestPassphrase() if needed - if err := network.Connect(); err != nil { - return fmt.Errorf("erreur lors de la connexion: %v", err) + // Use backend to connect + if err := wifiBackend.Connect(ssid, password); err != nil { + return err } // Poll for connection @@ -211,65 +132,51 @@ func Connect(ssid, password string) error { // Disconnect disconnects from the current WiFi network func Disconnect() error { - if err := station.Disconnect(); err != nil { - return fmt.Errorf("erreur lors de la déconnexion: %v", err) - } - return nil + return wifiBackend.Disconnect() } -// IsConnected checks if WiFi is connected using iwd +// IsConnected checks if WiFi is connected func IsConnected() bool { - state, err := station.GetState() + state, err := wifiBackend.GetConnectionState() if err != nil { return false } - return state == iwd.StateConnected + return state == backend.StateConnected } // GetConnectedSSID returns the SSID of the currently connected network func GetConnectedSSID() string { - network, err := station.GetConnectedNetwork() - if err != nil { - return "" - } - - props, err := network.GetProperties() - if err != nil { - return "" - } - - return props.Name + return wifiBackend.GetConnectedSSID() } // GetConnectionState returns the current WiFi connection state func GetConnectionState() string { - state, err := station.GetState() + state, err := wifiBackend.GetConnectionState() if err != nil { - return string(iwd.StateDisconnected) + return string(backend.StateDisconnected) } return string(state) } -// StartEventMonitoring initializes D-Bus signal monitoring and WebSocket broadcasting +// StartEventMonitoring initializes signal monitoring and WebSocket broadcasting func StartEventMonitoring() error { // Initialize broadcaster wifiBroadcaster = NewWifiBroadcaster() - // Create signal monitor - eventMonitor = iwd.NewSignalMonitor(dbusConn, station) + // Set up callbacks + callbacks := backend.EventCallbacks{ + OnStateChange: handleStateChange, + OnScanComplete: handleScanComplete, + } - // Register callbacks - eventMonitor.OnStateChange(handleStateChange) - eventMonitor.OnScanComplete(handleScanComplete) - - // Start monitoring - return eventMonitor.Start() + // Start backend monitoring + return wifiBackend.StartEventMonitoring(callbacks) } -// StopEventMonitoring stops D-Bus signal monitoring +// StopEventMonitoring stops signal monitoring func StopEventMonitoring() { - if eventMonitor != nil { - eventMonitor.Stop() + if wifiBackend != nil { + wifiBackend.StopEventMonitoring() } } @@ -288,7 +195,7 @@ func UnregisterWebSocketClient(conn *websocket.Conn) { } // handleStateChange is called when WiFi connection state changes -func handleStateChange(newState iwd.StationState, connectedSSID string) { +func handleStateChange(newState backend.ConnectionState, connectedSSID string) { if wifiBroadcaster != nil { wifiBroadcaster.BroadcastStateChange(string(newState), connectedSSID) } @@ -303,9 +210,9 @@ func handleScanComplete() { } } -// mapSecurityType maps iwd security types to display format -func mapSecurityType(iwdType string) string { - switch iwdType { +// mapSecurityType maps backend security types to display format +func mapSecurityType(securityType string) string { + switch securityType { case "open": return "Open" case "wep": @@ -319,25 +226,6 @@ func mapSecurityType(iwdType string) string { } } -// generateSyntheticBSSID generates a consistent fake BSSID from SSID -func generateSyntheticBSSID(ssid string) string { - // Use a simple hash approach - consistent per SSID - hash := 0 - for _, c := range ssid { - hash = ((hash << 5) - hash) + int(c) - } - - // Generate 6 bytes for MAC address - b1 := byte((hash >> 0) & 0xff) - b2 := byte((hash >> 8) & 0xff) - b3 := byte((hash >> 16) & 0xff) - b4 := byte((hash >> 24) & 0xff) - b5 := byte(len(ssid) & 0xff) - b6 := byte((len(ssid) >> 8) & 0xff) - - return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", b1, b2, b3, b4, b5, b6) -} - // signalToStrength converts signal level (dBm) to strength (1-5) func signalToStrength(level int) int { if level >= -30 { From 04ada45f44410779eddf897096f1b0b6bac6cb4e Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 1 Jan 2026 22:19:57 +0700 Subject: [PATCH 11/13] Implement wpa_supplicant backend --- internal/wifi/factory.go | 4 +- internal/wifi/wpasupplicant/backend.go | 265 +++++++++++++++++++++++ internal/wifi/wpasupplicant/bss.go | 157 ++++++++++++++ internal/wifi/wpasupplicant/interface.go | 146 +++++++++++++ internal/wifi/wpasupplicant/network.go | 41 ++++ internal/wifi/wpasupplicant/signals.go | 236 ++++++++++++++++++++ internal/wifi/wpasupplicant/types.go | 27 +++ 7 files changed, 874 insertions(+), 2 deletions(-) create mode 100644 internal/wifi/wpasupplicant/backend.go create mode 100644 internal/wifi/wpasupplicant/bss.go create mode 100644 internal/wifi/wpasupplicant/interface.go create mode 100644 internal/wifi/wpasupplicant/network.go create mode 100644 internal/wifi/wpasupplicant/signals.go create mode 100644 internal/wifi/wpasupplicant/types.go diff --git a/internal/wifi/factory.go b/internal/wifi/factory.go index 5b49c7a..fdb63cc 100644 --- a/internal/wifi/factory.go +++ b/internal/wifi/factory.go @@ -5,6 +5,7 @@ import ( "github.com/nemunaire/repeater/internal/wifi/backend" "github.com/nemunaire/repeater/internal/wifi/iwd" + "github.com/nemunaire/repeater/internal/wifi/wpasupplicant" ) // createBackend creates the appropriate WiFi backend based on the backend name @@ -13,8 +14,7 @@ func createBackend(backendName string) (backend.WiFiBackend, error) { case "iwd": return iwd.NewIWDBackend(), nil case "wpasupplicant": - // TODO: Implement wpa_supplicant backend - return nil, fmt.Errorf("wpa_supplicant backend not yet implemented") + return wpasupplicant.NewWPABackend(), nil default: return nil, fmt.Errorf("invalid wifi backend: %s (must be 'iwd' or 'wpasupplicant')", backendName) } diff --git a/internal/wifi/wpasupplicant/backend.go b/internal/wifi/wpasupplicant/backend.go new file mode 100644 index 0000000..406b8f5 --- /dev/null +++ b/internal/wifi/wpasupplicant/backend.go @@ -0,0 +1,265 @@ +package wpasupplicant + +import ( + "fmt" + "time" + + "github.com/godbus/dbus/v5" + "github.com/nemunaire/repeater/internal/wifi/backend" +) + +// WPABackend implements the WiFiBackend interface for wpa_supplicant +type WPABackend struct { + conn *dbus.Conn + wpasupplicant dbus.BusObject + iface *WPAInterface + signalMonitor *SignalMonitor + interfaceName string + currentNetwork dbus.ObjectPath +} + +// NewWPABackend creates a new wpa_supplicant backend instance +func NewWPABackend() *WPABackend { + return &WPABackend{} +} + +// Initialize initializes the wpa_supplicant backend with the given interface name +func (b *WPABackend) Initialize(interfaceName string) error { + b.interfaceName = interfaceName + var err error + + // Connect to D-Bus + b.conn, err = dbus.SystemBus() + if err != nil { + return fmt.Errorf("failed to connect to D-Bus: %v", err) + } + + // Get wpa_supplicant root object + b.wpasupplicant = b.conn.Object(Service, dbus.ObjectPath(RootPath)) + + // Get interface path for the given interface name + interfacePath, err := b.getInterfacePath(interfaceName) + if err != nil { + return fmt.Errorf("failed to get interface for %s: %v", interfaceName, err) + } + + b.iface = NewWPAInterface(b.conn, interfacePath) + + return nil +} + +// getInterfacePath gets or creates the wpa_supplicant Interface object path +func (b *WPABackend) getInterfacePath(interfaceName string) (dbus.ObjectPath, error) { + var interfacePath dbus.ObjectPath + + // Try to get existing interface + err := b.wpasupplicant.Call(Service+".GetInterface", 0, interfaceName).Store(&interfacePath) + if err == nil { + return interfacePath, nil + } + + // Interface doesn't exist, create it + args := map[string]dbus.Variant{ + "Ifname": dbus.MakeVariant(interfaceName), + } + + err = b.wpasupplicant.Call(Service+".CreateInterface", 0, args).Store(&interfacePath) + if err != nil { + return "", fmt.Errorf("failed to create interface: %v", err) + } + + return interfacePath, nil +} + +// Close closes the D-Bus connection +func (b *WPABackend) Close() error { + if b.conn != nil { + b.conn.Close() + } + return nil +} + +// ScanNetworks triggers a network scan +func (b *WPABackend) ScanNetworks() error { + err := b.iface.Scan("active") + if err != nil { + return fmt.Errorf("failed to trigger scan: %v", err) + } + return nil +} + +// GetOrderedNetworks returns networks sorted by signal strength in backend-agnostic format +func (b *WPABackend) GetOrderedNetworks() ([]backend.BackendNetwork, error) { + // Get BSS list + bssPaths, err := b.iface.GetBSSs() + if err != nil { + return nil, fmt.Errorf("failed to get BSSs: %v", err) + } + + var networks []backend.BackendNetwork + seenSSIDs := make(map[string]bool) + + // Iterate through BSSs and collect network info + for _, bssPath := range bssPaths { + bss := NewBSS(b.conn, bssPath) + props, err := bss.GetProperties() + if err != nil { + continue + } + + ssid := string(props.SSID) + if ssid == "" || seenSSIDs[ssid] { + continue + } + seenSSIDs[ssid] = true + + // Get BSSID string + bssidStr, err := bss.GetBSSIDString() + if err != nil { + bssidStr = "" + } + + // Convert to backend-agnostic format + backendNet := backend.BackendNetwork{ + SSID: ssid, + SignalDBm: props.Signal, + SecurityType: props.DetermineSecurityType(), + BSSID: bssidStr, + Frequency: props.Frequency, + } + + networks = append(networks, backendNet) + } + + // Sort by signal strength (descending) + // Note: This is a simple bubble sort for demonstration + // In production, use sort.Slice + for i := 0; i < len(networks); i++ { + for j := i + 1; j < len(networks); j++ { + if networks[j].SignalDBm > networks[i].SignalDBm { + networks[i], networks[j] = networks[j], networks[i] + } + } + } + + return networks, nil +} + +// IsScanning checks if a scan is currently in progress +func (b *WPABackend) IsScanning() (bool, error) { + return b.iface.GetScanning() +} + +// Connect connects to a WiFi network +func (b *WPABackend) Connect(ssid, password string) error { + // Create network configuration + config := make(map[string]interface{}) + config["ssid"] = fmt.Sprintf("\"%s\"", ssid) // wpa_supplicant expects quoted SSID + + if password != "" { + // For WPA/WPA2-PSK networks + config["psk"] = fmt.Sprintf("\"%s\"", password) + } else { + // For open networks + config["key_mgmt"] = "NONE" + } + + // Add network + networkPath, err := b.iface.AddNetwork(config) + if err != nil { + return fmt.Errorf("failed to add network: %v", err) + } + + // Store current network path for cleanup + b.currentNetwork = networkPath + + // Select (connect to) the network + err = b.iface.SelectNetwork(networkPath) + if err != nil { + // Clean up network on failure + b.iface.RemoveNetwork(networkPath) + return fmt.Errorf("failed to select network: %v", err) + } + + return nil +} + +// Disconnect disconnects from the current WiFi network +func (b *WPABackend) Disconnect() error { + // Disconnect from current network + if err := b.iface.Disconnect(); err != nil { + return fmt.Errorf("failed to disconnect: %v", err) + } + + // Remove the network configuration if we have one + if b.currentNetwork != "" && b.currentNetwork != "/" { + b.iface.RemoveNetwork(b.currentNetwork) + b.currentNetwork = "" + } + + return nil +} + +// GetConnectionState returns the current WiFi connection state +func (b *WPABackend) GetConnectionState() (backend.ConnectionState, error) { + state, err := b.iface.GetState() + if err != nil { + return backend.StateDisconnected, err + } + return mapWPAState(state), nil +} + +// GetConnectedSSID returns the SSID of the currently connected network +func (b *WPABackend) GetConnectedSSID() string { + // Get current BSS + bssPath, err := b.iface.GetCurrentBSS() + if err != nil || bssPath == "/" { + return "" + } + + // Get BSS object + bss := NewBSS(b.conn, bssPath) + ssid, err := bss.GetSSIDString() + if err != nil { + return "" + } + + return ssid +} + +// StartEventMonitoring starts monitoring WiFi events +func (b *WPABackend) StartEventMonitoring(callbacks backend.EventCallbacks) error { + // Create signal monitor + b.signalMonitor = NewSignalMonitor(b.conn, b.iface) + + // Start monitoring + return b.signalMonitor.Start(callbacks) +} + +// StopEventMonitoring stops monitoring WiFi events +func (b *WPABackend) StopEventMonitoring() { + if b.signalMonitor != nil { + b.signalMonitor.Stop() + } +} + +// Wait for scan to complete (helper method) +func (b *WPABackend) waitForScanComplete(timeout time.Duration) error { + start := time.Now() + for { + if time.Since(start) > timeout { + return fmt.Errorf("scan timeout") + } + + scanning, err := b.iface.GetScanning() + if err != nil { + return err + } + + if !scanning { + return nil + } + + time.Sleep(100 * time.Millisecond) + } +} diff --git a/internal/wifi/wpasupplicant/bss.go b/internal/wifi/wpasupplicant/bss.go new file mode 100644 index 0000000..799e9f9 --- /dev/null +++ b/internal/wifi/wpasupplicant/bss.go @@ -0,0 +1,157 @@ +package wpasupplicant + +import ( + "fmt" + + "github.com/godbus/dbus/v5" +) + +// BSS represents a wpa_supplicant BSS (Basic Service Set) object +type BSS struct { + path dbus.ObjectPath + conn *dbus.Conn + obj dbus.BusObject +} + +// BSSProperties holds the properties of a BSS +type BSSProperties struct { + SSID []byte + BSSID []byte + Signal int16 // Signal strength in dBm + Frequency uint32 // Frequency in MHz + Privacy bool // Whether encryption is enabled + RSN map[string]dbus.Variant + WPA map[string]dbus.Variant +} + +// NewBSS creates a new BSS instance +func NewBSS(conn *dbus.Conn, path dbus.ObjectPath) *BSS { + return &BSS{ + path: path, + conn: conn, + obj: conn.Object(Service, path), + } +} + +// GetProperties returns all properties of the BSS +func (b *BSS) GetProperties() (*BSSProperties, error) { + props := &BSSProperties{} + + // Get SSID + if ssidProp, err := b.obj.GetProperty(BSSInterface + ".SSID"); err == nil { + if ssid, ok := ssidProp.Value().([]byte); ok { + props.SSID = ssid + } + } + + // Get BSSID + if bssidProp, err := b.obj.GetProperty(BSSInterface + ".BSSID"); err == nil { + if bssid, ok := bssidProp.Value().([]byte); ok { + props.BSSID = bssid + } + } + + // Get Signal + if signalProp, err := b.obj.GetProperty(BSSInterface + ".Signal"); err == nil { + if signal, ok := signalProp.Value().(int16); ok { + props.Signal = signal + } + } + + // Get Frequency + if freqProp, err := b.obj.GetProperty(BSSInterface + ".Frequency"); err == nil { + if freq, ok := freqProp.Value().(uint16); ok { + props.Frequency = uint32(freq) + } + } + + // Get Privacy + if privacyProp, err := b.obj.GetProperty(BSSInterface + ".Privacy"); err == nil { + if privacy, ok := privacyProp.Value().(bool); ok { + props.Privacy = privacy + } + } + + // Get RSN (WPA2) information + if rsnProp, err := b.obj.GetProperty(BSSInterface + ".RSN"); err == nil { + if rsn, ok := rsnProp.Value().(map[string]dbus.Variant); ok { + props.RSN = rsn + } + } + + // Get WPA information + if wpaProp, err := b.obj.GetProperty(BSSInterface + ".WPA"); err == nil { + if wpa, ok := wpaProp.Value().(map[string]dbus.Variant); ok { + props.WPA = wpa + } + } + + return props, nil +} + +// GetSSIDString returns the SSID as a string +func (b *BSS) GetSSIDString() (string, error) { + prop, err := b.obj.GetProperty(BSSInterface + ".SSID") + if err != nil { + return "", fmt.Errorf("failed to get SSID property: %v", err) + } + + ssid, ok := prop.Value().([]byte) + if !ok { + return "", fmt.Errorf("SSID property is not a byte array") + } + + return string(ssid), nil +} + +// GetBSSIDString returns the BSSID as a formatted MAC address string +func (b *BSS) GetBSSIDString() (string, error) { + prop, err := b.obj.GetProperty(BSSInterface + ".BSSID") + if err != nil { + return "", fmt.Errorf("failed to get BSSID property: %v", err) + } + + bssid, ok := prop.Value().([]byte) + if !ok || len(bssid) != 6 { + return "", fmt.Errorf("BSSID property is not a valid MAC address") + } + + return fmt.Sprintf("%02x:%02x:%02x:%02x:%02x:%02x", + bssid[0], bssid[1], bssid[2], bssid[3], bssid[4], bssid[5]), nil +} + +// GetSignal returns the signal strength in dBm +func (b *BSS) GetSignal() (int16, error) { + prop, err := b.obj.GetProperty(BSSInterface + ".Signal") + if err != nil { + return 0, fmt.Errorf("failed to get Signal property: %v", err) + } + + signal, ok := prop.Value().(int16) + if !ok { + return 0, fmt.Errorf("Signal property is not an int16") + } + + return signal, nil +} + +// DetermineSecurityType determines the security type based on BSS properties +func (p *BSSProperties) DetermineSecurityType() string { + // Check for WPA2 (RSN) + if len(p.RSN) > 0 { + return "psk" + } + + // Check for WPA + if len(p.WPA) > 0 { + return "psk" + } + + // Check for WEP (privacy but no WPA/RSN) + if p.Privacy { + return "wep" + } + + // Open network + return "open" +} diff --git a/internal/wifi/wpasupplicant/interface.go b/internal/wifi/wpasupplicant/interface.go new file mode 100644 index 0000000..ecc9fbb --- /dev/null +++ b/internal/wifi/wpasupplicant/interface.go @@ -0,0 +1,146 @@ +package wpasupplicant + +import ( + "fmt" + + "github.com/godbus/dbus/v5" +) + +// WPAInterface represents a wpa_supplicant Interface object +type WPAInterface struct { + path dbus.ObjectPath + conn *dbus.Conn + obj dbus.BusObject +} + +// NewWPAInterface creates a new WPAInterface instance +func NewWPAInterface(conn *dbus.Conn, path dbus.ObjectPath) *WPAInterface { + return &WPAInterface{ + path: path, + conn: conn, + obj: conn.Object(Service, path), + } +} + +// Scan triggers a network scan +func (i *WPAInterface) Scan(scanType string) error { + args := map[string]interface{}{ + "Type": scanType, // "active" or "passive" + } + + err := i.obj.Call(InterfaceInterface+".Scan", 0, args).Err + if err != nil { + return fmt.Errorf("scan failed: %v", err) + } + return nil +} + +// GetBSSs returns a list of BSS (Basic Service Set) object paths +func (i *WPAInterface) GetBSSs() ([]dbus.ObjectPath, error) { + prop, err := i.obj.GetProperty(InterfaceInterface + ".BSSs") + if err != nil { + return nil, fmt.Errorf("failed to get BSSs property: %v", err) + } + + bsss, ok := prop.Value().([]dbus.ObjectPath) + if !ok { + return nil, fmt.Errorf("BSSs property is not an array of ObjectPath") + } + + return bsss, nil +} + +// GetState returns the current connection state +func (i *WPAInterface) GetState() (WPAState, error) { + prop, err := i.obj.GetProperty(InterfaceInterface + ".State") + if err != nil { + return "", fmt.Errorf("failed to get State property: %v", err) + } + + state, ok := prop.Value().(string) + if !ok { + return "", fmt.Errorf("State property is not a string") + } + + return WPAState(state), nil +} + +// GetCurrentBSS returns the currently connected BSS object path +func (i *WPAInterface) GetCurrentBSS() (dbus.ObjectPath, error) { + prop, err := i.obj.GetProperty(InterfaceInterface + ".CurrentBSS") + if err != nil { + return "", fmt.Errorf("failed to get CurrentBSS property: %v", err) + } + + bss, ok := prop.Value().(dbus.ObjectPath) + if !ok { + return "", fmt.Errorf("CurrentBSS property is not an ObjectPath") + } + + return bss, nil +} + +// AddNetwork creates a new network configuration +func (i *WPAInterface) AddNetwork(config map[string]interface{}) (dbus.ObjectPath, error) { + var networkPath dbus.ObjectPath + + // Convert config to proper DBus variant format + dbusConfig := make(map[string]dbus.Variant) + for key, value := range config { + dbusConfig[key] = dbus.MakeVariant(value) + } + + err := i.obj.Call(InterfaceInterface+".AddNetwork", 0, dbusConfig).Store(&networkPath) + if err != nil { + return "", fmt.Errorf("failed to add network: %v", err) + } + + return networkPath, nil +} + +// SelectNetwork connects to a network +func (i *WPAInterface) SelectNetwork(networkPath dbus.ObjectPath) error { + err := i.obj.Call(InterfaceInterface+".SelectNetwork", 0, networkPath).Err + if err != nil { + return fmt.Errorf("failed to select network: %v", err) + } + return nil +} + +// RemoveNetwork removes a network configuration +func (i *WPAInterface) RemoveNetwork(networkPath dbus.ObjectPath) error { + err := i.obj.Call(InterfaceInterface+".RemoveNetwork", 0, networkPath).Err + if err != nil { + return fmt.Errorf("failed to remove network: %v", err) + } + return nil +} + +// Disconnect disconnects from the current network +func (i *WPAInterface) Disconnect() error { + err := i.obj.Call(InterfaceInterface+".Disconnect", 0).Err + if err != nil { + return fmt.Errorf("disconnect failed: %v", err) + } + return nil +} + +// GetPath returns the D-Bus object path for this interface +func (i *WPAInterface) GetPath() dbus.ObjectPath { + return i.path +} + +// GetScanning returns whether a scan is currently in progress +func (i *WPAInterface) GetScanning() (bool, error) { + prop, err := i.obj.GetProperty(InterfaceInterface + ".Scanning") + if err != nil { + return false, fmt.Errorf("failed to get Scanning property: %v", err) + } + + scanning, ok := prop.Value().(bool) + if !ok { + return false, fmt.Errorf("Scanning property is not a boolean") + } + + return scanning, nil +} diff --git a/internal/wifi/wpasupplicant/network.go b/internal/wifi/wpasupplicant/network.go new file mode 100644 index 0000000..3152a6b --- /dev/null +++ b/internal/wifi/wpasupplicant/network.go @@ -0,0 +1,41 @@ +package wpasupplicant + +import ( + "github.com/godbus/dbus/v5" +) + +// Network represents a wpa_supplicant Network configuration object +type Network struct { + path dbus.ObjectPath + conn *dbus.Conn + obj dbus.BusObject +} + +// NewNetwork creates a new Network instance +func NewNetwork(conn *dbus.Conn, path dbus.ObjectPath) *Network { + return &Network{ + path: path, + conn: conn, + obj: conn.Object(Service, path), + } +} + +// GetPath returns the D-Bus object path for this network +func (n *Network) GetPath() dbus.ObjectPath { + return n.path +} + +// GetProperties returns properties of the network configuration +func (n *Network) GetProperties() (map[string]dbus.Variant, error) { + prop, err := n.obj.GetProperty(NetworkInterface + ".Properties") + if err != nil { + return nil, err + } + + props, ok := prop.Value().(map[string]dbus.Variant) + if !ok { + return nil, nil + } + + return props, nil +} diff --git a/internal/wifi/wpasupplicant/signals.go b/internal/wifi/wpasupplicant/signals.go new file mode 100644 index 0000000..a17a292 --- /dev/null +++ b/internal/wifi/wpasupplicant/signals.go @@ -0,0 +1,236 @@ +package wpasupplicant + +import ( + "log" + "sync" + + "github.com/godbus/dbus/v5" + "github.com/nemunaire/repeater/internal/wifi/backend" +) + +// SignalMonitor monitors D-Bus signals from wpa_supplicant +type SignalMonitor struct { + conn *dbus.Conn + iface *WPAInterface + callbacks backend.EventCallbacks + + // Signal channel + signalChan chan *dbus.Signal + + // Control + stopChan chan struct{} + mu sync.RWMutex + running bool + + // State tracking + lastState WPAState +} + +// NewSignalMonitor creates a new signal monitor +func NewSignalMonitor(conn *dbus.Conn, iface *WPAInterface) *SignalMonitor { + return &SignalMonitor{ + conn: conn, + iface: iface, + signalChan: make(chan *dbus.Signal, 100), + stopChan: make(chan struct{}), + } +} + +// Start begins monitoring D-Bus signals +func (sm *SignalMonitor) Start(callbacks backend.EventCallbacks) error { + sm.mu.Lock() + if sm.running { + sm.mu.Unlock() + return nil + } + sm.running = true + sm.callbacks = callbacks + sm.mu.Unlock() + + interfacePath := sm.iface.GetPath() + + // Add signal match for PropertiesChanged on Interface + matchOptions := []dbus.MatchOption{ + dbus.WithMatchObjectPath(interfacePath), + dbus.WithMatchInterface("org.freedesktop.DBus.Properties"), + dbus.WithMatchMember("PropertiesChanged"), + } + + if err := sm.conn.AddMatchSignal(matchOptions...); err != nil { + sm.mu.Lock() + sm.running = false + sm.mu.Unlock() + return err + } + + // Add signal match for ScanDone + scanDoneOptions := []dbus.MatchOption{ + dbus.WithMatchObjectPath(interfacePath), + dbus.WithMatchInterface(InterfaceInterface), + dbus.WithMatchMember("ScanDone"), + } + + if err := sm.conn.AddMatchSignal(scanDoneOptions...); err != nil { + sm.mu.Lock() + sm.running = false + sm.mu.Unlock() + return err + } + + // Register signal channel + sm.conn.Signal(sm.signalChan) + + // Get initial state + state, err := sm.iface.GetState() + if err == nil { + sm.lastState = state + } + + // Start monitoring goroutine + go sm.monitor() + + log.Printf("D-Bus signal monitoring started for wpa_supplicant interface %s", interfacePath) + return nil +} + +// Stop stops monitoring D-Bus signals +func (sm *SignalMonitor) Stop() { + sm.mu.Lock() + if !sm.running { + sm.mu.Unlock() + return + } + sm.running = false + sm.mu.Unlock() + + // Signal stop + close(sm.stopChan) + + // Remove signal channel + sm.conn.RemoveSignal(sm.signalChan) + + log.Printf("D-Bus signal monitoring stopped for wpa_supplicant") +} + +// monitor is the main signal processing loop +func (sm *SignalMonitor) monitor() { + for { + select { + case sig := <-sm.signalChan: + sm.handleSignal(sig) + case <-sm.stopChan: + return + } + } +} + +// handleSignal processes a D-Bus signal +func (sm *SignalMonitor) handleSignal(sig *dbus.Signal) { + // Handle ScanDone signal + if sig.Name == InterfaceInterface+".ScanDone" { + sm.handleScanDone(sig) + return + } + + // Handle PropertiesChanged signals + if sig.Name != "org.freedesktop.DBus.Properties.PropertiesChanged" { + return + } + + // Verify signal is from Interface + if len(sig.Body) < 2 { + return + } + + interfaceName, ok := sig.Body[0].(string) + if !ok || interfaceName != InterfaceInterface { + return + } + + // Parse changed properties + changedProps, ok := sig.Body[1].(map[string]dbus.Variant) + if !ok { + return + } + + // Check for State property change + if stateVariant, ok := changedProps["State"]; ok { + if state, ok := stateVariant.Value().(string); ok { + sm.handleStateChange(WPAState(state)) + } + } + + // Check for CurrentBSS property change (connection status) + if _, ok := changedProps["CurrentBSS"]; ok { + // BSS changed, trigger state update + sm.handleConnectionChange() + } +} + +// handleStateChange processes a state change +func (sm *SignalMonitor) handleStateChange(state WPAState) { + sm.lastState = state + + sm.mu.RLock() + callback := sm.callbacks.OnStateChange + sm.mu.RUnlock() + + if callback == nil { + return + } + + // Map wpa_supplicant state to backend state + backendState := mapWPAState(state) + + // Get connected SSID if connected + ssid := "" + if backendState == backend.StateConnected { + if bssPath, err := sm.iface.GetCurrentBSS(); err == nil && bssPath != "/" { + bss := NewBSS(sm.conn, bssPath) + if ssidStr, err := bss.GetSSIDString(); err == nil { + ssid = ssidStr + } + } + } + + callback(backendState, ssid) +} + +// handleConnectionChange processes connection changes +func (sm *SignalMonitor) handleConnectionChange() { + // Get current state and trigger state change callback + state, err := sm.iface.GetState() + if err != nil { + return + } + + sm.handleStateChange(state) +} + +// handleScanDone processes scan completion +func (sm *SignalMonitor) handleScanDone(sig *dbus.Signal) { + sm.mu.RLock() + callback := sm.callbacks.OnScanComplete + sm.mu.RUnlock() + + if callback != nil { + callback() + } +} + +// mapWPAState maps wpa_supplicant states to backend-agnostic states +func mapWPAState(state WPAState) backend.ConnectionState { + switch state { + case StateCompleted: + return backend.StateConnected + case StateAuthenticating, StateAssociating, StateAssociated, State4WayHandshake, StateGroupHandshake: + return backend.StateConnecting + case StateDisconnected, StateInactive, StateInterfaceDisabled: + return backend.StateDisconnected + case StateScanning: + // Keep as disconnected if just scanning + return backend.StateDisconnected + default: + return backend.StateDisconnected + } +} diff --git a/internal/wifi/wpasupplicant/types.go b/internal/wifi/wpasupplicant/types.go new file mode 100644 index 0000000..85fec0f --- /dev/null +++ b/internal/wifi/wpasupplicant/types.go @@ -0,0 +1,27 @@ +package wpasupplicant + +const ( + // D-Bus service and interfaces + Service = "fi.w1.wpa_supplicant1" + RootPath = "/fi/w1/wpa_supplicant1" + InterfaceInterface = "fi.w1.wpa_supplicant1.Interface" + BSSInterface = "fi.w1.wpa_supplicant1.BSS" + NetworkInterface = "fi.w1.wpa_supplicant1.Network" +) + +// WPAState represents the wpa_supplicant connection state +type WPAState string + +const ( + // wpa_supplicant state strings + StateDisconnected WPAState = "disconnected" + StateInactive WPAState = "inactive" + StateScanning WPAState = "scanning" + StateAuthenticating WPAState = "authenticating" + StateAssociating WPAState = "associating" + StateAssociated WPAState = "associated" + State4WayHandshake WPAState = "4way_handshake" + StateGroupHandshake WPAState = "group_handshake" + StateCompleted WPAState = "completed" + StateInterfaceDisabled WPAState = "interface_disabled" +) From 69594c2fe4cef13df921f1b221aca0bfc1b9ee5e Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 1 Jan 2026 22:37:02 +0700 Subject: [PATCH 12/13] syslog filter could take several filters --- internal/config/cli.go | 2 +- internal/config/config.go | 22 +++++++++++----------- internal/config/custom.go | 18 ++++++++++++++++++ internal/syslog/syslog.go | 29 ++++++++++++++++++----------- 4 files changed, 48 insertions(+), 23 deletions(-) diff --git a/internal/config/cli.go b/internal/config/cli.go index a975875..c5be56d 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -14,7 +14,7 @@ func declareFlags(o *Config) { flag.StringVar(&o.ARPTablePath, "arp-table-path", "/proc/net/arp", "Path to ARP table file") flag.BoolVar(&o.SyslogEnabled, "syslog-enabled", false, "Enable syslog tailing for iwd messages") flag.StringVar(&o.SyslogPath, "syslog-path", "/var/log/messages", "Path to syslog file") - flag.StringVar(&o.SyslogFilter, "syslog-filter", "daemon.info iwd:", "Filter string for syslog lines") + flag.Var(&StringArray{&o.SyslogFilter}, "daemon.info iwd:", "Filter string for syslog lines") flag.StringVar(&o.SyslogSource, "syslog-source", "iwd", "Source name for syslog entries in logs") } diff --git a/internal/config/config.go b/internal/config/config.go index 89f1d9f..48f66f6 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,16 +9,16 @@ import ( ) type Config struct { - Bind string - WifiInterface string - WifiBackend string - UseARPDiscovery bool - DHCPLeasesPath string - ARPTablePath string - SyslogEnabled bool - SyslogPath string - SyslogFilter string - SyslogSource string + Bind string + WifiInterface string + WifiBackend string + UseARPDiscovery bool + DHCPLeasesPath string + ARPTablePath string + SyslogEnabled bool + SyslogPath string + SyslogFilter []string + SyslogSource string } // ConsolidateConfig fills an Options struct by reading configuration from @@ -35,7 +35,7 @@ func ConsolidateConfig() (opts *Config, err error) { ARPTablePath: "/proc/net/arp", SyslogEnabled: false, SyslogPath: "/var/log/messages", - SyslogFilter: "daemon.info iwd:", + SyslogFilter: []string{"daemon.info wpa_supplicant:", "daemon.info iwd:", "daemon.info hostapd:"}, SyslogSource: "iwd", } diff --git a/internal/config/custom.go b/internal/config/custom.go index 71428fc..f038b7e 100644 --- a/internal/config/custom.go +++ b/internal/config/custom.go @@ -1,9 +1,27 @@ package config import ( + "fmt" "net/url" ) +// StringArray is a custom type for handling multiple string values in flags. +type StringArray struct { + Array *[]string +} + +// String returns a string representation of the StringArray. +func (i *StringArray) String() string { + return fmt.Sprintf("%v", i.Array) +} + +// Set appends a new string value to the StringArray. +func (i *StringArray) Set(value string) error { + *i.Array = append(*i.Array, value) + + return nil +} + type URL struct { URL *url.URL } diff --git a/internal/syslog/syslog.go b/internal/syslog/syslog.go index 35aa352..605a1fe 100644 --- a/internal/syslog/syslog.go +++ b/internal/syslog/syslog.go @@ -14,9 +14,9 @@ import ( // SyslogTailer tails a syslog file and filters messages to the logging system. type SyslogTailer struct { - path string - filter string - source string + path string + filters []string + source string file *os.File done chan struct{} @@ -29,12 +29,12 @@ type SyslogTailer struct { // path: Path to the syslog file (e.g., "/var/log/messages") // filter: Filter string to match in lines (e.g., "daemon.info iwd:") // source: Source name for logging (e.g., "iwd") -func NewSyslogTailer(path, filter, source string) *SyslogTailer { +func NewSyslogTailer(path string, filters []string, source string) *SyslogTailer { return &SyslogTailer{ - path: path, - filter: filter, - source: source, - done: make(chan struct{}), + path: path, + filters: filters, + source: source, + done: make(chan struct{}), } } @@ -185,15 +185,22 @@ func (t *SyslogTailer) readLines(file *os.File) error { line := scanner.Text() - // Check if the line contains the filter string - if !strings.Contains(line, t.filter) { + // Check if the line contains any of the filter strings + var matchedFilter string + for _, filter := range t.filters { + if strings.Contains(line, filter) { + matchedFilter = filter + break + } + } + if matchedFilter == "" { continue } // Parse the syslog line to extract the message // We look for "iwd:" (or whatever comes after the filter) // The filter is "daemon.info iwd:" so we want to extract text after "iwd:" - daemonPrefix := extractDaemonPrefix(t.filter) + daemonPrefix := extractDaemonPrefix(matchedFilter) message, ok := ParseSyslogLine(line, daemonPrefix) if !ok { // Couldn't parse the line, skip it From 2922a037243549cf70cc00a42a36ea1f885eb34f Mon Sep 17 00:00:00 2001 From: Pierre-Olivier Mercier Date: Thu, 1 Jan 2026 23:29:34 +0700 Subject: [PATCH 13/13] Refactor stations discovery and add hostapd discovery --- internal/api/handlers/handlers.go | 4 +- internal/app/app.go | 98 ++++++- internal/config/cli.go | 3 +- internal/config/config.go | 43 +-- internal/device/device.go | 224 --------------- internal/station/arp/backend.go | 177 ++++++++++++ internal/station/arp/parser.go | 64 +++++ internal/station/backend/types.go | 99 +++++++ internal/station/dhcp/backend.go | 184 +++++++++++++ internal/station/dhcp/parser.go | 72 +++++ internal/station/factory.go | 24 ++ internal/station/hostapd/backend.go | 345 ++++++++++++++++++++++++ internal/station/hostapd/correlation.go | 130 +++++++++ internal/station/hostapd/types.go | 10 + internal/station/station.go | 111 ++++++++ 15 files changed, 1339 insertions(+), 249 deletions(-) delete mode 100644 internal/device/device.go create mode 100644 internal/station/arp/backend.go create mode 100644 internal/station/arp/parser.go create mode 100644 internal/station/backend/types.go create mode 100644 internal/station/dhcp/backend.go create mode 100644 internal/station/dhcp/parser.go create mode 100644 internal/station/factory.go create mode 100644 internal/station/hostapd/backend.go create mode 100644 internal/station/hostapd/correlation.go create mode 100644 internal/station/hostapd/types.go create mode 100644 internal/station/station.go diff --git a/internal/api/handlers/handlers.go b/internal/api/handlers/handlers.go index 6ef8ec1..7c87a7c 100644 --- a/internal/api/handlers/handlers.go +++ b/internal/api/handlers/handlers.go @@ -5,10 +5,10 @@ import ( "github.com/gin-gonic/gin" "github.com/nemunaire/repeater/internal/config" - "github.com/nemunaire/repeater/internal/device" "github.com/nemunaire/repeater/internal/hotspot" "github.com/nemunaire/repeater/internal/logging" "github.com/nemunaire/repeater/internal/models" + "github.com/nemunaire/repeater/internal/station" "github.com/nemunaire/repeater/internal/wifi" ) @@ -120,7 +120,7 @@ func ToggleHotspot(c *gin.Context, status *models.SystemStatus) { // GetDevices returns connected devices func GetDevices(c *gin.Context, cfg *config.Config) { - devices, err := device.GetConnectedDevices(cfg) + devices, err := station.GetStations() if err != nil { logging.AddLog("Système", "Erreur lors de la récupération des appareils: "+err.Error()) c.JSON(http.StatusInternalServerError, gin.H{"error": "Erreur lors de la récupération des appareils"}) diff --git a/internal/app/app.go b/internal/app/app.go index 27299df..3e55b49 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -11,10 +11,11 @@ import ( "github.com/nemunaire/repeater/internal/api" "github.com/nemunaire/repeater/internal/config" - "github.com/nemunaire/repeater/internal/device" "github.com/nemunaire/repeater/internal/hotspot" "github.com/nemunaire/repeater/internal/logging" "github.com/nemunaire/repeater/internal/models" + "github.com/nemunaire/repeater/internal/station" + "github.com/nemunaire/repeater/internal/station/backend" "github.com/nemunaire/repeater/internal/syslog" "github.com/nemunaire/repeater/internal/wifi" ) @@ -62,6 +63,28 @@ func (a *App) Initialize(cfg *config.Config) error { // Don't fail - polling fallback still works } + // Initialize station backend + stationConfig := backend.BackendConfig{ + InterfaceName: cfg.HotspotInterface, + ARPTablePath: cfg.ARPTablePath, + DHCPLeasesPath: cfg.DHCPLeasesPath, + HostapdInterface: cfg.HotspotInterface, + } + if err := station.Initialize(cfg.StationBackend, stationConfig); err != nil { + log.Printf("Warning: Station backend initialization failed: %v", err) + // Don't fail - will continue without station discovery + } else { + // Start event monitoring for station events + if err := station.StartEventMonitoring(backend.EventCallbacks{ + OnStationConnected: a.handleStationConnected, + OnStationDisconnected: a.handleStationDisconnected, + OnStationUpdated: a.handleStationUpdated, + }); err != nil { + log.Printf("Warning: Station event monitoring failed: %v", err) + // Don't fail - polling fallback still works + } + } + // Start syslog tailing if enabled if cfg.SyslogEnabled { a.SyslogTailer = syslog.NewSyslogTailer( @@ -98,6 +121,10 @@ func (a *App) Shutdown() { a.SyslogTailer.Stop() } + // Stop station monitoring and close backend + station.StopEventMonitoring() + station.Close() + wifi.StopEventMonitoring() wifi.Close() logging.AddLog("Système", "Application arrêtée") @@ -181,10 +208,9 @@ func (a *App) periodicDeviceUpdate() { defer ticker.Stop() for range ticker.C { - devices, err := device.GetConnectedDevices(a.Config) + devices, err := station.GetStations() if err != nil { log.Printf("Error getting connected devices: %v", err) - continue } a.StatusMutex.Lock() @@ -193,3 +219,69 @@ func (a *App) periodicDeviceUpdate() { a.StatusMutex.Unlock() } } + +// handleStationConnected handles station connection events +func (a *App) handleStationConnected(st backend.Station) { + a.StatusMutex.Lock() + defer a.StatusMutex.Unlock() + + // Convert backend.Station to models.ConnectedDevice + device := models.ConnectedDevice{ + Name: st.Hostname, + Type: st.Type, + MAC: st.MAC, + IP: st.IP, + } + + // Check if device already exists + found := false + for i, d := range a.Status.ConnectedDevices { + if d.MAC == device.MAC { + a.Status.ConnectedDevices[i] = device + found = true + break + } + } + + // Add new device if not found + if !found { + a.Status.ConnectedDevices = append(a.Status.ConnectedDevices, device) + a.Status.ConnectedCount = len(a.Status.ConnectedDevices) + logging.AddLog("Stations", "Device connected: "+device.MAC+" ("+device.IP+")") + } +} + +// handleStationDisconnected handles station disconnection events +func (a *App) handleStationDisconnected(mac string) { + a.StatusMutex.Lock() + defer a.StatusMutex.Unlock() + + // Remove device from list + for i, d := range a.Status.ConnectedDevices { + if d.MAC == mac { + a.Status.ConnectedDevices = append(a.Status.ConnectedDevices[:i], a.Status.ConnectedDevices[i+1:]...) + a.Status.ConnectedCount = len(a.Status.ConnectedDevices) + logging.AddLog("Stations", "Device disconnected: "+mac) + break + } + } +} + +// handleStationUpdated handles station update events +func (a *App) handleStationUpdated(st backend.Station) { + a.StatusMutex.Lock() + defer a.StatusMutex.Unlock() + + // Update existing device + for i, d := range a.Status.ConnectedDevices { + if d.MAC == st.MAC { + a.Status.ConnectedDevices[i] = models.ConnectedDevice{ + Name: st.Hostname, + Type: st.Type, + MAC: st.MAC, + IP: st.IP, + } + break + } + } +} diff --git a/internal/config/cli.go b/internal/config/cli.go index c5be56d..49013b3 100644 --- a/internal/config/cli.go +++ b/internal/config/cli.go @@ -8,8 +8,9 @@ import ( func declareFlags(o *Config) { flag.StringVar(&o.Bind, "bind", ":8081", "Bind port/socket") flag.StringVar(&o.WifiInterface, "wifi-interface", "wlan0", "WiFi interface name") + flag.StringVar(&o.HotspotInterface, "hotspot-interface", "wlan1", "Hotspot WiFi interface name") flag.StringVar(&o.WifiBackend, "wifi-backend", "", "WiFi backend to use: 'iwd' or 'wpasupplicant' (required)") - flag.BoolVar(&o.UseARPDiscovery, "use-arp-discovery", true, "Use ARP table for device discovery instead of DHCP leases") + flag.StringVar(&o.StationBackend, "station-backend", "hostapd", "Station discovery backend: 'arp', 'dhcp', or 'hostapd'") flag.StringVar(&o.DHCPLeasesPath, "dhcp-leases-path", "/var/lib/dhcp/dhcpd.leases", "Path to DHCP leases file") flag.StringVar(&o.ARPTablePath, "arp-table-path", "/proc/net/arp", "Path to ARP table file") flag.BoolVar(&o.SyslogEnabled, "syslog-enabled", false, "Enable syslog tailing for iwd messages") diff --git a/internal/config/config.go b/internal/config/config.go index 48f66f6..3cd2fa7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -9,16 +9,17 @@ import ( ) type Config struct { - Bind string - WifiInterface string - WifiBackend string - UseARPDiscovery bool - DHCPLeasesPath string - ARPTablePath string - SyslogEnabled bool - SyslogPath string - SyslogFilter []string - SyslogSource string + Bind string + WifiInterface string + HotspotInterface string + WifiBackend string + StationBackend string // "arp", "dhcp", or "hostapd" + DHCPLeasesPath string + ARPTablePath string + SyslogEnabled bool + SyslogPath string + SyslogFilter []string + SyslogSource string } // ConsolidateConfig fills an Options struct by reading configuration from @@ -28,15 +29,15 @@ type Config struct { func ConsolidateConfig() (opts *Config, err error) { // Define defaults options opts = &Config{ - Bind: ":8080", - WifiInterface: "wlan0", - UseARPDiscovery: true, - DHCPLeasesPath: "/var/lib/dhcp/dhcpd.leases", - ARPTablePath: "/proc/net/arp", - SyslogEnabled: false, - SyslogPath: "/var/log/messages", - SyslogFilter: []string{"daemon.info wpa_supplicant:", "daemon.info iwd:", "daemon.info hostapd:"}, - SyslogSource: "iwd", + Bind: ":8080", + WifiInterface: "wlan0", + HotspotInterface: "wlan1", + DHCPLeasesPath: "/var/lib/dhcp/dhcpd.leases", + ARPTablePath: "/proc/net/arp", + SyslogEnabled: false, + SyslogPath: "/var/log/messages", + SyslogFilter: []string{"daemon.info wpa_supplicant:", "daemon.info iwd:", "daemon.info hostapd:"}, + SyslogSource: "iwd", } declareFlags(opts) @@ -81,6 +82,10 @@ func ConsolidateConfig() (opts *Config, err error) { log.Fatalf("wifi-backend must be set to 'iwd' or 'wpasupplicant' (got: '%s')", opts.WifiBackend) } + if opts.StationBackend != "arp" && opts.StationBackend != "dhcp" && opts.StationBackend != "hostapd" { + log.Fatalf("station-backend must be set to 'arp', 'dhcp', or 'hostapd' (got: '%s')", opts.StationBackend) + } + return } diff --git a/internal/device/device.go b/internal/device/device.go deleted file mode 100644 index f57dfa5..0000000 --- a/internal/device/device.go +++ /dev/null @@ -1,224 +0,0 @@ -package device - -import ( - "bufio" - "fmt" - "net" - "os" - "os/exec" - "regexp" - "strings" - - "github.com/nemunaire/repeater/internal/config" - "github.com/nemunaire/repeater/internal/models" -) - -// ARPEntry represents an entry in the ARP table -type ARPEntry struct { - IP net.IP - HWType int - Flags int - HWAddress net.HardwareAddr - Mask string - Device string -} - -// GetConnectedDevices returns a list of connected devices -func GetConnectedDevices(cfg *config.Config) ([]models.ConnectedDevice, error) { - if cfg.UseARPDiscovery { - return getDevicesFromARP(cfg) - } - return getDevicesFromDHCP(cfg) -} - -// getDevicesFromARP discovers devices using ARP table -func getDevicesFromARP(cfg *config.Config) ([]models.ConnectedDevice, error) { - var devices []models.ConnectedDevice - - arpEntries, err := parseARPTable(cfg.ARPTablePath) - if err != nil { - return devices, err - } - - for _, entry := range arpEntries { - // Only include entries with valid flags (2 = COMPLETE, 6 = COMPLETE|PERM) - if entry.Flags == 2 || entry.Flags == 6 { - device := models.ConnectedDevice{ - Name: "", // No hostname available from ARP - MAC: entry.HWAddress.String(), - IP: entry.IP.String(), - Type: guessDeviceType("", entry.HWAddress.String()), - } - devices = append(devices, device) - } - } - - return devices, nil -} - -// getDevicesFromDHCP discovers devices using DHCP leases and ARP validation -func getDevicesFromDHCP(cfg *config.Config) ([]models.ConnectedDevice, error) { - var devices []models.ConnectedDevice - - // Read DHCP leases - leases, err := parseDHCPLeases(cfg.DHCPLeasesPath) - if err != nil { - return devices, err - } - - // Get ARP information for validation - arpInfo, err := getARPInfo() - if err != nil { - return devices, err - } - - for _, lease := range leases { - device := models.ConnectedDevice{ - Name: lease.Hostname, - MAC: lease.MAC, - IP: lease.IP, - Type: guessDeviceType(lease.Hostname, lease.MAC), - } - - // Check if the device is still connected via ARP - if _, exists := arpInfo[lease.IP]; exists { - devices = append(devices, device) - } - } - - return devices, nil -} - -// parseARPTable reads and parses ARP table from /proc/net/arp format -func parseARPTable(path string) ([]ARPEntry, error) { - var entries []ARPEntry - - content, err := os.ReadFile(path) - if err != nil { - return entries, err - } - - for _, line := range strings.Split(string(content), "\n") { - fields := strings.Fields(line) - if len(fields) > 5 { - var entry ARPEntry - - // Parse HWType (hex format) - if _, err := fmt.Sscanf(fields[1], "0x%x", &entry.HWType); err != nil { - continue - } - - // Parse Flags (hex format) - if _, err := fmt.Sscanf(fields[2], "0x%x", &entry.Flags); err != nil { - continue - } - - // Parse IP address - entry.IP = net.ParseIP(fields[0]) - if entry.IP == nil { - continue - } - - // Parse MAC address - entry.HWAddress, err = net.ParseMAC(fields[3]) - if err != nil { - continue - } - - entry.Mask = fields[4] - entry.Device = fields[5] - - entries = append(entries, entry) - } - } - - return entries, nil -} - -// parseDHCPLeases reads and parses DHCP lease file -func parseDHCPLeases(path string) ([]models.DHCPLease, error) { - var leases []models.DHCPLease - - file, err := os.Open(path) - if err != nil { - return leases, err - } - defer file.Close() - - scanner := bufio.NewScanner(file) - var currentLease models.DHCPLease - - for scanner.Scan() { - line := strings.TrimSpace(scanner.Text()) - - if strings.HasPrefix(line, "lease ") { - ip := strings.Fields(line)[1] - currentLease = models.DHCPLease{IP: ip} - } else if strings.Contains(line, "hardware ethernet") { - mac := strings.Fields(line)[2] - mac = strings.TrimSuffix(mac, ";") - currentLease.MAC = mac - } else if strings.Contains(line, "client-hostname") { - hostname := strings.Fields(line)[1] - hostname = strings.Trim(hostname, `";`) - currentLease.Hostname = hostname - } else if line == "}" { - if currentLease.IP != "" && currentLease.MAC != "" { - leases = append(leases, currentLease) - } - currentLease = models.DHCPLease{} - } - } - - return leases, nil -} - -// getARPInfo retrieves ARP table information -func getARPInfo() (map[string]string, error) { - arpInfo := make(map[string]string) - - cmd := exec.Command("arp", "-a") - output, err := cmd.Output() - if err != nil { - return arpInfo, err - } - - lines := strings.Split(string(output), "\n") - for _, line := range lines { - if matches := regexp.MustCompile(`\(([^)]+)\) at ([0-9a-fA-F:]{17})`).FindStringSubmatch(line); len(matches) > 2 { - ip := matches[1] - mac := matches[2] - arpInfo[ip] = mac - } - } - - return arpInfo, nil -} - -// guessDeviceType attempts to guess device type from hostname and MAC address -func guessDeviceType(hostname, mac string) string { - hostname = strings.ToLower(hostname) - - if strings.Contains(hostname, "iphone") || strings.Contains(hostname, "android") { - return "mobile" - } else if strings.Contains(hostname, "ipad") || strings.Contains(hostname, "tablet") { - return "tablet" - } else if strings.Contains(hostname, "macbook") || strings.Contains(hostname, "laptop") { - return "laptop" - } - - // Guess by MAC prefix (OUI) - if len(mac) >= 8 { - macPrefix := strings.ToUpper(mac[:8]) - switch macPrefix { - case "00:50:56", "00:0C:29", "00:05:69": // VMware - return "laptop" - case "08:00:27": // VirtualBox - return "laptop" - default: - return "mobile" - } - } - - return "unknown" -} diff --git a/internal/station/arp/backend.go b/internal/station/arp/backend.go new file mode 100644 index 0000000..5b4e975 --- /dev/null +++ b/internal/station/arp/backend.go @@ -0,0 +1,177 @@ +package arp + +import ( + "sync" + "time" + + "github.com/nemunaire/repeater/internal/station/backend" +) + +// Backend implements StationBackend using ARP table discovery +type Backend struct { + arpTablePath string + lastStations map[string]backend.Station // Key: MAC address + callbacks backend.EventCallbacks + stopChan chan struct{} + mu sync.RWMutex + running bool +} + +// NewBackend creates a new ARP backend +func NewBackend() *Backend { + return &Backend{ + lastStations: make(map[string]backend.Station), + stopChan: make(chan struct{}), + } +} + +// Initialize initializes the ARP backend +func (b *Backend) Initialize(config backend.BackendConfig) error { + b.mu.Lock() + defer b.mu.Unlock() + + b.arpTablePath = config.ARPTablePath + if b.arpTablePath == "" { + b.arpTablePath = "/proc/net/arp" + } + + return nil +} + +// Close cleans up backend resources +func (b *Backend) Close() error { + b.StopEventMonitoring() + return nil +} + +// GetStations returns all connected stations from ARP table +func (b *Backend) GetStations() ([]backend.Station, error) { + b.mu.RLock() + arpTablePath := b.arpTablePath + b.mu.RUnlock() + + arpEntries, err := parseARPTable(arpTablePath) + if err != nil { + return nil, err + } + + var stations []backend.Station + for _, entry := range arpEntries { + // Only include entries with valid flags (2 = COMPLETE, 6 = COMPLETE|PERM) + if entry.Flags == 2 || entry.Flags == 6 { + st := backend.Station{ + MAC: entry.HWAddress.String(), + IP: entry.IP.String(), + Hostname: "", // No hostname available from ARP + Type: backend.GuessDeviceType("", entry.HWAddress.String()), + Signal: 0, // Not available from ARP + RxBytes: 0, // Not available from ARP + TxBytes: 0, // Not available from ARP + ConnectedAt: time.Now(), + } + stations = append(stations, st) + } + } + + return stations, nil +} + +// StartEventMonitoring starts monitoring for station events via polling +func (b *Backend) StartEventMonitoring(callbacks backend.EventCallbacks) error { + b.mu.Lock() + defer b.mu.Unlock() + + if b.running { + return nil + } + + b.callbacks = callbacks + b.running = true + + // Start polling goroutine + go b.pollLoop() + + return nil +} + +// StopEventMonitoring stops event monitoring +func (b *Backend) StopEventMonitoring() { + b.mu.Lock() + if !b.running { + b.mu.Unlock() + return + } + b.running = false + b.mu.Unlock() + + close(b.stopChan) +} + +// SupportsRealTimeEvents returns false (ARP is polling-based) +func (b *Backend) SupportsRealTimeEvents() bool { + return false +} + +// pollLoop polls the ARP table and simulates events +func (b *Backend) pollLoop() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + b.checkForChanges() + case <-b.stopChan: + return + } + } +} + +// checkForChanges compares current state with last state and triggers callbacks +func (b *Backend) checkForChanges() { + // Get current stations + current, err := b.GetStations() + if err != nil { + return + } + + // Build map of current stations + currentMap := make(map[string]backend.Station) + for _, station := range current { + currentMap[station.MAC] = station + } + + b.mu.Lock() + defer b.mu.Unlock() + + // Check for new stations (connected) + for mac, station := range currentMap { + if _, exists := b.lastStations[mac]; !exists { + // New station connected + if b.callbacks.OnStationConnected != nil { + go b.callbacks.OnStationConnected(station) + } + } else { + // Check for updates (IP change, etc.) + oldStation := b.lastStations[mac] + if oldStation.IP != station.IP || oldStation.Hostname != station.Hostname { + if b.callbacks.OnStationUpdated != nil { + go b.callbacks.OnStationUpdated(station) + } + } + } + } + + // Check for disconnected stations + for mac := range b.lastStations { + if _, exists := currentMap[mac]; !exists { + // Station disconnected + if b.callbacks.OnStationDisconnected != nil { + go b.callbacks.OnStationDisconnected(mac) + } + } + } + + // Update last state + b.lastStations = currentMap +} diff --git a/internal/station/arp/parser.go b/internal/station/arp/parser.go new file mode 100644 index 0000000..ca40f01 --- /dev/null +++ b/internal/station/arp/parser.go @@ -0,0 +1,64 @@ +package arp + +import ( + "fmt" + "net" + "os" + "strings" +) + +// ARPEntry represents an entry in the ARP table +type ARPEntry struct { + IP net.IP + HWType int + Flags int + HWAddress net.HardwareAddr + Mask string + Device string +} + +// parseARPTable reads and parses ARP table from /proc/net/arp format +func parseARPTable(path string) ([]ARPEntry, error) { + var entries []ARPEntry + + content, err := os.ReadFile(path) + if err != nil { + return entries, err + } + + for _, line := range strings.Split(string(content), "\n") { + fields := strings.Fields(line) + if len(fields) > 5 { + var entry ARPEntry + + // Parse HWType (hex format) + if _, err := fmt.Sscanf(fields[1], "0x%x", &entry.HWType); err != nil { + continue + } + + // Parse Flags (hex format) + if _, err := fmt.Sscanf(fields[2], "0x%x", &entry.Flags); err != nil { + continue + } + + // Parse IP address + entry.IP = net.ParseIP(fields[0]) + if entry.IP == nil { + continue + } + + // Parse MAC address + entry.HWAddress, err = net.ParseMAC(fields[3]) + if err != nil { + continue + } + + entry.Mask = fields[4] + entry.Device = fields[5] + + entries = append(entries, entry) + } + } + + return entries, nil +} diff --git a/internal/station/backend/types.go b/internal/station/backend/types.go new file mode 100644 index 0000000..9874d5c --- /dev/null +++ b/internal/station/backend/types.go @@ -0,0 +1,99 @@ +package backend + +import ( + "strings" + "time" +) + +// StationBackend defines the interface for station/device discovery backends. +// Implementations include ARP-based, DHCP-based, and Hostapd DBus-based discovery. +type StationBackend interface { + // Initialize initializes the backend with the given configuration + Initialize(config BackendConfig) error + + // Close cleans up backend resources + Close() error + + // GetStations returns all currently connected stations + GetStations() ([]Station, error) + + // StartEventMonitoring starts monitoring for station events + // Backends that don't support real-time events will poll and simulate events + StartEventMonitoring(callbacks EventCallbacks) error + + // StopEventMonitoring stops event monitoring + StopEventMonitoring() + + // SupportsRealTimeEvents returns true if backend supports real-time events (e.g., DBus) + // Returns false for polling-based backends (ARP, DHCP) + SupportsRealTimeEvents() bool +} + +// Station represents a connected device in a backend-agnostic format +type Station struct { + MAC string // Hardware MAC address (required, primary identifier) + IP string // IP address (may be empty for some backends initially) + Hostname string // Device hostname (may be empty) + Type string // Device type: "mobile", "laptop", "tablet", "unknown" + Signal int32 // Signal strength in dBm (0 if not available) + RxBytes uint64 // Received bytes (0 if not available) + TxBytes uint64 // Transmitted bytes (0 if not available) + ConnectedAt time.Time // When station connected (best effort) +} + +// EventCallbacks defines callback functions for station events. +// Backends call these when stations connect, disconnect, or update. +type EventCallbacks struct { + // OnStationConnected is called when a new station connects + OnStationConnected func(station Station) + + // OnStationDisconnected is called when a station disconnects + OnStationDisconnected func(mac string) + + // OnStationUpdated is called when station information changes + // (e.g., IP discovered, signal strength changed) + OnStationUpdated func(station Station) +} + +// BackendConfig provides configuration for backend initialization +type BackendConfig struct { + // Common + InterfaceName string // Network interface (e.g., "wlan1") + + // ARP-specific + ARPTablePath string // Path to /proc/net/arp (default: "/proc/net/arp") + + // DHCP-specific + DHCPLeasesPath string // Path to DHCP leases file + + // Hostapd-specific + HostapdInterface string // Hostapd interface name for DBus +} + +// GuessDeviceType attempts to guess device type from hostname and MAC address +func GuessDeviceType(hostname, mac string) string { + hostname = strings.ToLower(hostname) + + if strings.Contains(hostname, "iphone") || strings.Contains(hostname, "android") { + return "mobile" + } else if strings.Contains(hostname, "ipad") || strings.Contains(hostname, "tablet") { + return "tablet" + } else if strings.Contains(hostname, "macbook") || strings.Contains(hostname, "laptop") { + return "laptop" + } + + // Guess by MAC prefix (OUI) + if len(mac) >= 8 { + macPrefix := strings.ToUpper(mac[:8]) + switch macPrefix { + case "00:50:56", "00:0C:29", "00:05:69": // VMware + return "laptop" + case "08:00:27": // VirtualBox + return "laptop" + default: + return "mobile" + } + } + + return "unknown" +} diff --git a/internal/station/dhcp/backend.go b/internal/station/dhcp/backend.go new file mode 100644 index 0000000..54abf95 --- /dev/null +++ b/internal/station/dhcp/backend.go @@ -0,0 +1,184 @@ +package dhcp + +import ( + "sync" + "time" + + "github.com/nemunaire/repeater/internal/station/backend" +) + +// Backend implements StationBackend using DHCP lease discovery +type Backend struct { + dhcpLeasesPath string + lastStations map[string]backend.Station // Key: MAC address + callbacks backend.EventCallbacks + stopChan chan struct{} + mu sync.RWMutex + running bool +} + +// NewBackend creates a new DHCP backend +func NewBackend() *Backend { + return &Backend{ + lastStations: make(map[string]backend.Station), + stopChan: make(chan struct{}), + } +} + +// Initialize initializes the DHCP backend +func (b *Backend) Initialize(config backend.BackendConfig) error { + b.mu.Lock() + defer b.mu.Unlock() + + b.dhcpLeasesPath = config.DHCPLeasesPath + if b.dhcpLeasesPath == "" { + b.dhcpLeasesPath = "/var/lib/dhcp/dhcpd.leases" + } + + return nil +} + +// Close cleans up backend resources +func (b *Backend) Close() error { + b.StopEventMonitoring() + return nil +} + +// GetStations returns all connected stations from DHCP leases validated by ARP +func (b *Backend) GetStations() ([]backend.Station, error) { + b.mu.RLock() + dhcpLeasesPath := b.dhcpLeasesPath + b.mu.RUnlock() + + // Read DHCP leases + leases, err := parseDHCPLeases(dhcpLeasesPath) + if err != nil { + return nil, err + } + + // Get ARP information for validation + arpInfo, err := getARPInfo() + if err != nil { + return nil, err + } + + var stations []backend.Station + for _, lease := range leases { + // Check if the device is still connected via ARP + if _, exists := arpInfo[lease.IP]; exists { + st := backend.Station{ + MAC: lease.MAC, + IP: lease.IP, + Hostname: lease.Hostname, + Type: backend.GuessDeviceType(lease.Hostname, lease.MAC), + Signal: 0, // Not available from DHCP + RxBytes: 0, // Not available from DHCP + TxBytes: 0, // Not available from DHCP + ConnectedAt: time.Now(), + } + stations = append(stations, st) + } + } + + return stations, nil +} + +// StartEventMonitoring starts monitoring for station events via polling +func (b *Backend) StartEventMonitoring(callbacks backend.EventCallbacks) error { + b.mu.Lock() + defer b.mu.Unlock() + + if b.running { + return nil + } + + b.callbacks = callbacks + b.running = true + + // Start polling goroutine + go b.pollLoop() + + return nil +} + +// StopEventMonitoring stops event monitoring +func (b *Backend) StopEventMonitoring() { + b.mu.Lock() + if !b.running { + b.mu.Unlock() + return + } + b.running = false + b.mu.Unlock() + + close(b.stopChan) +} + +// SupportsRealTimeEvents returns false (DHCP is polling-based) +func (b *Backend) SupportsRealTimeEvents() bool { + return false +} + +// pollLoop polls DHCP leases and simulates events +func (b *Backend) pollLoop() { + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + b.checkForChanges() + case <-b.stopChan: + return + } + } +} + +// checkForChanges compares current state with last state and triggers callbacks +func (b *Backend) checkForChanges() { + // Get current stations + current, err := b.GetStations() + if err != nil { + return + } + + // Build map of current stations + currentMap := make(map[string]backend.Station) + for _, st := range current { + currentMap[st.MAC] = st + } + + b.mu.Lock() + defer b.mu.Unlock() + + // Check for new stations (connected) + for mac, st := range currentMap { + if _, exists := b.lastStations[mac]; !exists { + // New station connected + if b.callbacks.OnStationConnected != nil { + go b.callbacks.OnStationConnected(st) + } + } else { + // Check for updates (IP change, hostname change, etc.) + oldStation := b.lastStations[mac] + if oldStation.IP != st.IP || oldStation.Hostname != st.Hostname { + if b.callbacks.OnStationUpdated != nil { + go b.callbacks.OnStationUpdated(st) + } + } + } + } + + // Check for disconnected stations + for mac := range b.lastStations { + if _, exists := currentMap[mac]; !exists { + // Station disconnected + if b.callbacks.OnStationDisconnected != nil { + go b.callbacks.OnStationDisconnected(mac) + } + } + } + + // Update last state + b.lastStations = currentMap +} diff --git a/internal/station/dhcp/parser.go b/internal/station/dhcp/parser.go new file mode 100644 index 0000000..efcd583 --- /dev/null +++ b/internal/station/dhcp/parser.go @@ -0,0 +1,72 @@ +package dhcp + +import ( + "bufio" + "os" + "os/exec" + "regexp" + "strings" + + "github.com/nemunaire/repeater/internal/models" +) + +// parseDHCPLeases reads and parses DHCP lease file +func parseDHCPLeases(path string) ([]models.DHCPLease, error) { + var leases []models.DHCPLease + + file, err := os.Open(path) + if err != nil { + return leases, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + var currentLease models.DHCPLease + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + if strings.HasPrefix(line, "lease ") { + ip := strings.Fields(line)[1] + currentLease = models.DHCPLease{IP: ip} + } else if strings.Contains(line, "hardware ethernet") { + mac := strings.Fields(line)[2] + mac = strings.TrimSuffix(mac, ";") + currentLease.MAC = mac + } else if strings.Contains(line, "client-hostname") { + hostname := strings.Fields(line)[1] + hostname = strings.Trim(hostname, `";`) + currentLease.Hostname = hostname + } else if line == "}" { + if currentLease.IP != "" && currentLease.MAC != "" { + leases = append(leases, currentLease) + } + currentLease = models.DHCPLease{} + } + } + + return leases, nil +} + +// getARPInfo retrieves ARP table information using arp command +// Returns a map of IP -> MAC address +func getARPInfo() (map[string]string, error) { + arpInfo := make(map[string]string) + + cmd := exec.Command("arp", "-a") + output, err := cmd.Output() + if err != nil { + return arpInfo, err + } + + lines := strings.Split(string(output), "\n") + for _, line := range lines { + if matches := regexp.MustCompile(`\(([^)]+)\) at ([0-9a-fA-F:]{17})`).FindStringSubmatch(line); len(matches) > 2 { + ip := matches[1] + mac := matches[2] + arpInfo[ip] = mac + } + } + + return arpInfo, nil +} diff --git a/internal/station/factory.go b/internal/station/factory.go new file mode 100644 index 0000000..94bfc2b --- /dev/null +++ b/internal/station/factory.go @@ -0,0 +1,24 @@ +package station + +import ( + "fmt" + + "github.com/nemunaire/repeater/internal/station/arp" + "github.com/nemunaire/repeater/internal/station/backend" + "github.com/nemunaire/repeater/internal/station/dhcp" + "github.com/nemunaire/repeater/internal/station/hostapd" +) + +// createBackend creates a station backend based on the backend name +func createBackend(backendName string) (backend.StationBackend, error) { + switch backendName { + case "arp": + return arp.NewBackend(), nil + case "dhcp": + return dhcp.NewBackend(), nil + case "hostapd": + return hostapd.NewBackend(), nil + default: + return nil, fmt.Errorf("invalid station backend: %s (must be 'arp', 'dhcp', or 'hostapd')", backendName) + } +} diff --git a/internal/station/hostapd/backend.go b/internal/station/hostapd/backend.go new file mode 100644 index 0000000..ce5708a --- /dev/null +++ b/internal/station/hostapd/backend.go @@ -0,0 +1,345 @@ +package hostapd + +import ( + "bufio" + "bytes" + "fmt" + "log" + "os/exec" + "strconv" + "strings" + "sync" + "time" + + "github.com/nemunaire/repeater/internal/station/backend" +) + +// Backend implements StationBackend using hostapd_cli +type Backend struct { + interfaceName string + hostapdCLI string // Path to hostapd_cli executable + + stations map[string]*HostapdStation // Key: MAC address + callbacks backend.EventCallbacks + + mu sync.RWMutex + running bool + stopCh chan struct{} + + // IP correlation - will be populated by periodic DHCP lease correlation + ipByMAC map[string]string // MAC -> IP mapping +} + +// NewBackend creates a new hostapd backend +func NewBackend() *Backend { + return &Backend{ + stations: make(map[string]*HostapdStation), + ipByMAC: make(map[string]string), + hostapdCLI: "hostapd_cli", + stopCh: make(chan struct{}), + } +} + +// Initialize initializes the hostapd backend +func (b *Backend) Initialize(config backend.BackendConfig) error { + b.mu.Lock() + defer b.mu.Unlock() + + b.interfaceName = config.InterfaceName + if b.interfaceName == "" { + b.interfaceName = "wlan1" // Default AP interface + } + + // Check if hostapd_cli is available + if _, err := exec.LookPath(b.hostapdCLI); err != nil { + return fmt.Errorf("hostapd_cli not found in PATH: %w", err) + } + + // Verify we can communicate with hostapd + if err := b.runCommand("ping"); err != nil { + return fmt.Errorf("failed to communicate with hostapd on interface %s: %w", b.interfaceName, err) + } + + log.Printf("Hostapd backend initialized for interface %s", b.interfaceName) + + // Load initial station list + if err := b.loadStations(); err != nil { + log.Printf("Warning: Failed to load initial stations: %v", err) + } + + return nil +} + +// Close cleans up backend resources +func (b *Backend) Close() error { + b.StopEventMonitoring() + return nil +} + +// runCommand executes a hostapd_cli command and returns the output +func (b *Backend) runCommand(args ...string) error { + cmdArgs := []string{"-i", b.interfaceName} + cmdArgs = append(cmdArgs, args...) + cmd := exec.Command(b.hostapdCLI, cmdArgs...) + return cmd.Run() +} + +// runCommandOutput executes a hostapd_cli command and returns the output +func (b *Backend) runCommandOutput(args ...string) (string, error) { + cmdArgs := []string{"-i", b.interfaceName} + cmdArgs = append(cmdArgs, args...) + cmd := exec.Command(b.hostapdCLI, cmdArgs...) + out, err := cmd.Output() + if err != nil { + return "", err + } + return string(out), nil +} + +// GetStations returns all connected stations +func (b *Backend) GetStations() ([]backend.Station, error) { + b.mu.RLock() + defer b.mu.RUnlock() + + stations := make([]backend.Station, 0, len(b.stations)) + for mac, hs := range b.stations { + station := b.convertStation(mac, hs) + stations = append(stations, station) + } + + return stations, nil +} + +// StartEventMonitoring starts monitoring for station events via polling +func (b *Backend) StartEventMonitoring(callbacks backend.EventCallbacks) error { + b.mu.Lock() + defer b.mu.Unlock() + + if b.running { + return nil + } + + b.callbacks = callbacks + b.running = true + + // Start polling goroutine + go b.pollStations() + + log.Printf("Hostapd event monitoring started (polling mode)") + return nil +} + +// StopEventMonitoring stops event monitoring +func (b *Backend) StopEventMonitoring() { + b.mu.Lock() + if !b.running { + b.mu.Unlock() + return + } + b.running = false + b.mu.Unlock() + + close(b.stopCh) +} + +// SupportsRealTimeEvents returns false (hostapd_cli uses polling) +func (b *Backend) SupportsRealTimeEvents() bool { + return false +} + +// pollStations periodically polls for station changes +func (b *Backend) pollStations() { + ticker := time.NewTicker(2 * time.Second) + defer ticker.Stop() + + for { + select { + case <-b.stopCh: + return + case <-ticker.C: + if err := b.checkStationChanges(); err != nil { + log.Printf("Error polling stations: %v", err) + } + } + } +} + +// checkStationChanges checks for station connect/disconnect events +func (b *Backend) checkStationChanges() error { + // Get current stations from hostapd + currentStations, err := b.fetchStations() + if err != nil { + return err + } + + b.mu.Lock() + defer b.mu.Unlock() + + // Build a map of current MACs + currentMACs := make(map[string]bool) + for mac := range currentStations { + currentMACs[mac] = true + } + + // Check for new stations + for mac, station := range currentStations { + if _, exists := b.stations[mac]; !exists { + // New station connected + b.stations[mac] = station + if b.callbacks.OnStationConnected != nil { + st := b.convertStation(mac, station) + go b.callbacks.OnStationConnected(st) + } + log.Printf("Station connected: %s", mac) + } + } + + // Check for removed stations + for mac := range b.stations { + if !currentMACs[mac] { + // Station disconnected + delete(b.stations, mac) + delete(b.ipByMAC, mac) + if b.callbacks.OnStationDisconnected != nil { + go b.callbacks.OnStationDisconnected(mac) + } + log.Printf("Station disconnected: %s", mac) + } + } + + return nil +} + +// loadStations loads the initial list of stations from hostapd +func (b *Backend) loadStations() error { + stations, err := b.fetchStations() + if err != nil { + return err + } + + b.stations = stations + log.Printf("Loaded %d initial stations from hostapd", len(b.stations)) + return nil +} + +// fetchStations fetches all stations using hostapd_cli all_sta command +func (b *Backend) fetchStations() (map[string]*HostapdStation, error) { + output, err := b.runCommandOutput("all_sta") + if err != nil { + return nil, fmt.Errorf("failed to get stations: %w", err) + } + + return b.parseAllStaOutput(output), nil +} + +// parseAllStaOutput parses the output of "hostapd_cli all_sta" +func (b *Backend) parseAllStaOutput(output string) map[string]*HostapdStation { + stations := make(map[string]*HostapdStation) + scanner := bufio.NewScanner(bytes.NewBufferString(output)) + + var currentMAC string + var currentStation *HostapdStation + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if line == "" { + continue + } + + // Check if this is a MAC address line (starts the station block) + if !strings.Contains(line, "=") && len(line) == 17 && strings.Count(line, ":") == 5 { + // Save previous station if exists + if currentMAC != "" && currentStation != nil { + stations[currentMAC] = currentStation + } + // Start new station + currentMAC = strings.ToLower(line) + currentStation = &HostapdStation{} + continue + } + + // Parse key=value pairs + if currentStation != nil && strings.Contains(line, "=") { + parts := strings.SplitN(line, "=", 2) + if len(parts) != 2 { + continue + } + key := strings.TrimSpace(parts[0]) + value := strings.TrimSpace(parts[1]) + + switch key { + case "signal": + if v, err := strconv.Atoi(value); err == nil { + currentStation.Signal = int32(v) + } + case "rx_bytes": + if v, err := strconv.ParseUint(value, 10, 64); err == nil { + currentStation.RxBytes = v + } + case "tx_bytes": + if v, err := strconv.ParseUint(value, 10, 64); err == nil { + currentStation.TxBytes = v + } + } + } + } + + // Save last station + if currentMAC != "" && currentStation != nil { + stations[currentMAC] = currentStation + } + + return stations +} + +// convertStation converts HostapdStation to backend.Station +func (b *Backend) convertStation(mac string, hs *HostapdStation) backend.Station { + // Get IP address if available from correlation + ip := b.ipByMAC[mac] + + // Attempt hostname resolution if we have an IP + hostname := "" + // TODO: Could do reverse DNS lookup here if needed + + return backend.Station{ + MAC: mac, + IP: ip, + Hostname: hostname, + Type: backend.GuessDeviceType(hostname, mac), + Signal: hs.Signal, + RxBytes: hs.RxBytes, + TxBytes: hs.TxBytes, + ConnectedAt: time.Now(), // We don't have exact connection time + } +} + +// UpdateIPMapping updates the MAC -> IP mapping from external source (e.g., DHCP) +// This should be called periodically to correlate hostapd stations with IP addresses +func (b *Backend) UpdateIPMapping(macToIP map[string]string) { + b.mu.Lock() + defer b.mu.Unlock() + + // Track which stations got IP updates + updated := make(map[string]bool) + + for mac, ip := range macToIP { + if oldIP, exists := b.ipByMAC[mac]; exists && oldIP != ip { + // IP changed + updated[mac] = true + } else if !exists { + // New IP mapping + updated[mac] = true + } + b.ipByMAC[mac] = ip + } + + // Trigger update callbacks for stations that got new/changed IPs + for mac := range updated { + if station, exists := b.stations[mac]; exists { + if b.callbacks.OnStationUpdated != nil { + st := b.convertStation(mac, station) + go b.callbacks.OnStationUpdated(st) + } + } + } +} diff --git a/internal/station/hostapd/correlation.go b/internal/station/hostapd/correlation.go new file mode 100644 index 0000000..fe4361a --- /dev/null +++ b/internal/station/hostapd/correlation.go @@ -0,0 +1,130 @@ +package hostapd + +import ( + "bufio" + "log" + "os" + "strings" + "time" + + "github.com/nemunaire/repeater/internal/models" +) + +// DHCPCorrelator helps correlate hostapd stations with DHCP leases to discover IP addresses +type DHCPCorrelator struct { + backend *Backend + dhcpLeasesPath string + stopChan chan struct{} + running bool +} + +// NewDHCPCorrelator creates a new DHCP correlator +func NewDHCPCorrelator(backend *Backend, dhcpLeasesPath string) *DHCPCorrelator { + if dhcpLeasesPath == "" { + dhcpLeasesPath = "/var/lib/dhcp/dhcpd.leases" + } + + return &DHCPCorrelator{ + backend: backend, + dhcpLeasesPath: dhcpLeasesPath, + stopChan: make(chan struct{}), + } +} + +// Start begins periodic correlation of DHCP leases with hostapd stations +func (dc *DHCPCorrelator) Start() { + if dc.running { + return + } + + dc.running = true + go dc.correlationLoop() + log.Printf("DHCP correlation started for hostapd backend") +} + +// Stop stops the correlation loop +func (dc *DHCPCorrelator) Stop() { + if !dc.running { + return + } + + dc.running = false + close(dc.stopChan) + log.Printf("DHCP correlation stopped") +} + +// correlationLoop periodically correlates DHCP leases with stations +func (dc *DHCPCorrelator) correlationLoop() { + // Do an initial correlation immediately + dc.correlate() + + // Then correlate every 10 seconds + ticker := time.NewTicker(10 * time.Second) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + dc.correlate() + case <-dc.stopChan: + return + } + } +} + +// correlate performs one correlation cycle +func (dc *DHCPCorrelator) correlate() { + // Parse DHCP leases + leases, err := parseDHCPLeases(dc.dhcpLeasesPath) + if err != nil { + log.Printf("Warning: Failed to parse DHCP leases: %v", err) + return + } + + // Build MAC -> IP mapping + macToIP := make(map[string]string) + for _, lease := range leases { + macToIP[lease.MAC] = lease.IP + } + + // Update backend with IP mappings + dc.backend.UpdateIPMapping(macToIP) +} + +// parseDHCPLeases reads and parses DHCP lease file +func parseDHCPLeases(path string) ([]models.DHCPLease, error) { + var leases []models.DHCPLease + + file, err := os.Open(path) + if err != nil { + return leases, err + } + defer file.Close() + + scanner := bufio.NewScanner(file) + var currentLease models.DHCPLease + + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + + if strings.HasPrefix(line, "lease ") { + ip := strings.Fields(line)[1] + currentLease = models.DHCPLease{IP: ip} + } else if strings.Contains(line, "hardware ethernet") { + mac := strings.Fields(line)[2] + mac = strings.TrimSuffix(mac, ";") + currentLease.MAC = mac + } else if strings.Contains(line, "client-hostname") { + hostname := strings.Fields(line)[1] + hostname = strings.Trim(hostname, `";`) + currentLease.Hostname = hostname + } else if line == "}" { + if currentLease.IP != "" && currentLease.MAC != "" { + leases = append(leases, currentLease) + } + currentLease = models.DHCPLease{} + } + } + + return leases, scanner.Err() +} diff --git a/internal/station/hostapd/types.go b/internal/station/hostapd/types.go new file mode 100644 index 0000000..2521c1f --- /dev/null +++ b/internal/station/hostapd/types.go @@ -0,0 +1,10 @@ +package hostapd + +// HostapdStation represents station properties from hostapd_cli +type HostapdStation struct { + RxPackets uint64 + TxPackets uint64 + RxBytes uint64 + TxBytes uint64 + Signal int32 // Signal in dBm +} diff --git a/internal/station/station.go b/internal/station/station.go new file mode 100644 index 0000000..e6acaf1 --- /dev/null +++ b/internal/station/station.go @@ -0,0 +1,111 @@ +package station + +import ( + "sync" + + "github.com/nemunaire/repeater/internal/models" + "github.com/nemunaire/repeater/internal/station/backend" +) + +var ( + currentBackend backend.StationBackend + mu sync.RWMutex +) + +// Initialize initializes the station discovery backend +func Initialize(backendName string, config backend.BackendConfig) error { + mu.Lock() + defer mu.Unlock() + + // Close existing backend if any + if currentBackend != nil { + currentBackend.Close() + } + + // Create new backend + b, err := createBackend(backendName) + if err != nil { + return err + } + + // Initialize the backend + if err := b.Initialize(config); err != nil { + return err + } + + currentBackend = b + return nil +} + +// GetStations returns all connected stations as ConnectedDevice models +func GetStations() ([]models.ConnectedDevice, error) { + mu.RLock() + defer mu.RUnlock() + + if currentBackend == nil { + return nil, nil + } + + stations, err := currentBackend.GetStations() + if err != nil { + return nil, err + } + + // Convert backend.Station to models.ConnectedDevice + devices := make([]models.ConnectedDevice, len(stations)) + for i, s := range stations { + devices[i] = models.ConnectedDevice{ + Name: s.Hostname, + Type: s.Type, + MAC: s.MAC, + IP: s.IP, + } + } + + return devices, nil +} + +// StartEventMonitoring starts monitoring for station events +func StartEventMonitoring(callbacks backend.EventCallbacks) error { + mu.RLock() + defer mu.RUnlock() + + if currentBackend == nil { + return nil + } + + return currentBackend.StartEventMonitoring(callbacks) +} + +// StopEventMonitoring stops monitoring for station events +func StopEventMonitoring() { + mu.RLock() + defer mu.RUnlock() + + if currentBackend != nil { + currentBackend.StopEventMonitoring() + } +} + +// Close closes the current backend +func Close() { + mu.Lock() + defer mu.Unlock() + + if currentBackend != nil { + currentBackend.Close() + currentBackend = nil + } +} + +// SupportsRealTimeEvents returns true if the current backend supports real-time events +func SupportsRealTimeEvents() bool { + mu.RLock() + defer mu.RUnlock() + + if currentBackend == nil { + return false + } + + return currentBackend.SupportsRealTimeEvents() +}