diff --git a/Makefile b/Makefile index c696dca..6661cfb 100644 --- a/Makefile +++ b/Makefile @@ -22,6 +22,8 @@ BIN_DIR := $(shell pwd)/bin TEST_DIRS := $(shell find . -name "*_test.go" -type f -exec dirname {} \; | sort -u) +TEST_FORMAT := testdox + hash_resource: @shasum -a 256 resource/jetkvm_native | cut -d ' ' -f 1 > resource/jetkvm_native.sha256 @@ -59,6 +61,7 @@ build_dev_test: build_test2json build_gotestsum chmod +x $(BIN_DIR)/tests/run_all_tests; \ cp $(BIN_DIR)/test2json $(BIN_DIR)/tests/ && chmod +x $(BIN_DIR)/tests/test2json; \ cp $(BIN_DIR)/gotestsum $(BIN_DIR)/tests/ && chmod +x $(BIN_DIR)/tests/gotestsum; \ + echo $(TEST_FORMAT) > $(BIN_DIR)/tests/.test_format; \ tar czfv device-tests.tar.gz -C $(BIN_DIR)/tests . frontend: diff --git a/dev_deploy.sh b/dev_deploy.sh index aac9acb..78f0b21 100755 --- a/dev_deploy.sh +++ b/dev_deploy.sh @@ -45,6 +45,7 @@ LOG_TRACE_SCOPES="${LOG_TRACE_SCOPES:-jetkvm,cloud,websocket,native,jsonrpc}" RUN_GO_TESTS=false RUN_GO_TESTS_ONLY=false INSTALL_APP=false +TEST_FORMAT=${TEST_FORMAT:-"testdox"} # Parse command line arguments while [[ $# -gt 0 ]]; do @@ -105,7 +106,7 @@ fi if [ "$RUN_GO_TESTS" = true ]; then msg_info "▶ Building go tests" - make build_dev_test + make TEST_FORMAT=${TEST_FORMAT} build_dev_test msg_info "▶ Copying device-tests.tar.gz to remote host" ssh "${REMOTE_USER}@${REMOTE_HOST}" "cat > /tmp/device-tests.tar.gz" < device-tests.tar.gz @@ -116,7 +117,7 @@ set -e TMP_DIR=$(mktemp -d) cd ${TMP_DIR} tar zxf /tmp/device-tests.tar.gz -./gotestsum --format=testdox \ +./gotestsum --format=$(cat .test_format) \ --jsonfile=/tmp/device-tests.json \ --post-run-command 'sh -c "echo $TESTS_FAILED > /tmp/device-tests.failed"' \ --raw-command -- ./run_all_tests -json diff --git a/go.mod b/go.mod index 426f656..ded4639 100644 --- a/go.mod +++ b/go.mod @@ -8,13 +8,14 @@ require ( github.com/coder/websocket v1.8.13 github.com/coreos/go-oidc/v3 v3.14.1 github.com/creack/pty v1.1.24 - github.com/fsnotify/fsnotify v1.9.0 github.com/gin-contrib/logger v1.2.6 github.com/gin-gonic/gin v1.10.1 + github.com/go-co-op/gocron/v2 v2.16.3 github.com/google/uuid v1.6.0 github.com/guregu/null/v6 v6.0.0 github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341 github.com/hanwen/go-fuse/v2 v2.8.0 + github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f github.com/pion/logging v0.2.4 github.com/pion/mdns/v2 v2.0.7 github.com/pion/webrtc/v4 v4.1.3 @@ -28,9 +29,9 @@ require ( github.com/stretchr/testify v1.10.0 github.com/vishvananda/netlink v1.3.1 go.bug.st/serial v1.6.4 - golang.org/x/crypto v0.39.0 + golang.org/x/crypto v0.40.0 golang.org/x/net v0.41.0 - golang.org/x/sys v0.33.0 + golang.org/x/sys v0.34.0 ) replace github.com/pojntfx/go-nbd v0.3.2 => github.com/chemhack/go-nbd v0.0.0-20241006125820-59e45f5b1e7b @@ -50,15 +51,20 @@ require ( github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/validator/v10 v10.26.0 // indirect github.com/goccy/go-json v0.10.5 // indirect + github.com/jonboulle/clockwork v0.5.0 // indirect + github.com/josharian/native v1.1.0 // indirect github.com/json-iterator/go v1.1.12 // indirect github.com/klauspost/cpuid/v2 v2.2.10 // indirect github.com/leodido/go-urn v1.4.0 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mdlayher/packet v1.1.2 // indirect + github.com/mdlayher/socket v0.4.1 // indirect github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect github.com/modern-go/reflect2 v1.0.2 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/pelletier/go-toml/v2 v2.2.4 // indirect + github.com/pierrec/lz4/v4 v4.1.14 // indirect github.com/pilebones/go-udev v0.9.1 // indirect github.com/pion/datachannel v1.5.10 // indirect github.com/pion/dtls/v3 v3.0.6 // indirect @@ -75,14 +81,17 @@ require ( github.com/pion/turn/v4 v4.0.2 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.2 // indirect + github.com/robfig/cron/v3 v3.0.1 // indirect github.com/rogpeppe/go-internal v1.14.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect + github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923 // indirect github.com/ugorji/go/codec v1.3.0 // indirect github.com/vishvananda/netns v0.0.5 // indirect github.com/wlynxg/anet v0.0.5 // indirect golang.org/x/arch v0.18.0 // indirect golang.org/x/oauth2 v0.30.0 // indirect - golang.org/x/text v0.26.0 // indirect + golang.org/x/sync v0.16.0 // indirect + golang.org/x/text v0.27.0 // indirect google.golang.org/protobuf v1.36.6 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index a9f9b77..ab09b76 100644 --- a/go.sum +++ b/go.sum @@ -28,8 +28,6 @@ github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfv 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/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/gabriel-vasile/mimetype v1.4.9 h1:5k+WDwEsD9eTLL8Tz3L0VnmVh9QxGjRmjBvAG7U/oYY= github.com/gabriel-vasile/mimetype v1.4.9/go.mod h1:WnSQhFKJuBlRyLiKohA/2DtIlPFAbguNaG7QCHcyGok= github.com/gin-contrib/logger v1.2.6 h1:EPolruKUTzNXMVBD9LuAFQmRjTs7AH7yKGuXgYqrKWc= @@ -38,6 +36,8 @@ 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.10.1 h1:T0ujvqyCSqRopADpgPgiTT63DUQVSfojyME59Ei63pQ= github.com/gin-gonic/gin v1.10.1/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y= +github.com/go-co-op/gocron/v2 v2.16.3 h1:kYqukZqBa8RC2+AFAHnunmKcs9GRTjwBo8WRF3I6cbI= +github.com/go-co-op/gocron/v2 v2.16.3/go.mod h1:aTf7/+5Jo2E+cyAqq625UQ6DzpkV96b22VHIUAt6l3c= github.com/go-jose/go-jose/v4 v4.1.0 h1:cYSYxd3pw5zd2FSXk2vGdn9igQU2PS8MuxrCOCl0FdY= github.com/go-jose/go-jose/v4 v4.1.0/go.mod h1:GG/vqmYm3Von2nYiB2vGTXzdoNKE5tix5tuc6iAd+sw= github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s= @@ -62,6 +62,15 @@ github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341 h1:zPrkLSKi7kKJoN github.com/gwatts/rootcerts v0.0.0-20250601184604-370a9a75f341/go.mod h1:5Kt9XkWvkGi2OHOq0QsGxebHmhCcqJ8KCbNg/a6+n+g= github.com/hanwen/go-fuse/v2 v2.8.0 h1:wV8rG7rmCz8XHSOwBZhG5YcVqcYjkzivjmbaMafPlAs= github.com/hanwen/go-fuse/v2 v2.8.0/go.mod h1:yE6D2PqWwm3CbYRxFXV9xUd8Md5d6NG0WBs5spCswmI= +github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714 h1:/jC7qQFrv8CrSJVmaolDVOxTfS9kc36uB6H40kdbQq8= +github.com/hugelgupf/socketpair v0.0.0-20190730060125-05d35a94e714/go.mod h1:2Goc3h8EklBH5mspfHFxBnEoURQCGzQQH1ga9Myjvis= +github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f h1:dd33oobuIv9PcBVqvbEiCXEbNTomOHyj3WFuC5YiPRU= +github.com/insomniacslk/dhcp v0.0.0-20250417080101-5f8cf70e8c5f/go.mod h1:zhFlBeJssZ1YBCMZ5Lzu1pX4vhftDvU10WUVb1uXKtM= +github.com/jonboulle/clockwork v0.5.0 h1:Hyh9A8u51kptdkR+cqRpT1EebBwTn1oK9YfGYbdFz6I= +github.com/jonboulle/clockwork v0.5.0/go.mod h1:3mZlmanh0g2NDKO5TWZVJAfofYk64M7XN3SzBPjZF60= +github.com/josharian/native v1.0.1-0.20221213033349-c1e37c09b531/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= +github.com/josharian/native v1.1.0 h1:uuaP0hAbW7Y4l0ZRQ6C9zfb7Mg1mbFKry/xzDAfmtLA= +github.com/josharian/native v1.1.0/go.mod h1:7X/raswPFr05uY3HiLlYeyQntB6OO7E/d2Cu7qoaN2w= 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/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo= @@ -88,6 +97,10 @@ github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/ github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= 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/mdlayher/packet v1.1.2 h1:3Up1NG6LZrsgDVn6X4L9Ge/iyRyxFEFD9o6Pr3Q1nQY= +github.com/mdlayher/packet v1.1.2/go.mod h1:GEu1+n9sG5VtiRE4SydOmX5GTwyyYlteZiFU+x0kew4= +github.com/mdlayher/socket v0.4.1 h1:eM9y2/jlbs1M615oshPQOHZzj6R6wMT7bX5NPiQvn2U= +github.com/mdlayher/socket v0.4.1/go.mod h1:cAqeGjoufqdxWkD7DkpyS+wcefOtmu5OQ8KuoJGIReA= github.com/moby/sys/mountinfo v0.7.2 h1:1shs6aH5s4o5H2zQLn796ADW1wMrIwHsyJ2v9KouLrg= github.com/moby/sys/mountinfo v0.7.2/go.mod h1:1YOa8w8Ih7uW0wALDUgT1dTTSBrZ+HiBLGws92L2RU4= github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q= @@ -99,6 +112,8 @@ github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= 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/pierrec/lz4/v4 v4.1.14 h1:+fL8AQEZtz/ijeNnpduH0bROTu0O3NZAlPjQxGn8LwE= +github.com/pierrec/lz4/v4 v4.1.14/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pilebones/go-udev v0.9.1 h1:uN72M1C1fgzhsVmBGEM8w9RD1JY4iVsPZpr+Z6rb3O8= github.com/pilebones/go-udev v0.9.1/go.mod h1:Bgcl07crebF3JSeS4+nuaRvhWFdCeFoBhXXeAp93XNo= github.com/pion/datachannel v1.5.10 h1:ly0Q26K1i6ZkGf42W7D4hQYR90pZwzFOjTq5AuCKk4o= @@ -146,6 +161,8 @@ github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzM github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/psanford/httpreadat v0.1.0 h1:VleW1HS2zO7/4c7c7zNl33fO6oYACSagjJIyMIwZLUE= github.com/psanford/httpreadat v0.1.0/go.mod h1:Zg7P+TlBm3bYbyHTKv/EdtSJZn3qwbPwpfZ/I9GKCRE= +github.com/robfig/cron/v3 v3.0.1 h1:WdRxkvbJztn8LMz/QEvLN5sBU+xKpSqwwUO1Pjr4qDs= +github.com/robfig/cron/v3 v3.0.1/go.mod h1:eQICP3HwyT7UooqI/z+Ov+PtYAWygg1TEWWzGIFLtro= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0= @@ -156,6 +173,8 @@ github.com/sourcegraph/tf-dag v0.2.2-0.20250131204052-3e8ff1477b4f/go.mod h1:pzr 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/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY= +github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= @@ -165,6 +184,8 @@ github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOf github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= 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/u-root/uio v0.0.0-20230220225925-ffce2a382923 h1:tHNk7XK9GkmKUR6Gh8gVBKXc2MVSZ4G/NnWLtzw4gNA= +github.com/u-root/uio v0.0.0-20230220225925-ffce2a382923/go.mod h1:eLL9Nub3yfAho7qB0MzZizFhTU2QkLeoVsWdHtDW264= 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/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= @@ -175,23 +196,28 @@ github.com/wlynxg/anet v0.0.5 h1:J3VJGi1gvo0JwZ/P1/Yc/8p63SoW98B5dHkYDmpgvvU= github.com/wlynxg/anet v0.0.5/go.mod h1:eay5PRQr7fIVAMbTbchTnO9gG65Hg/uYGdc7mguHxoA= go.bug.st/serial v1.6.4 h1:7FmqNPgVp3pu2Jz5PoPtbZ9jJO5gnEnZIvnI1lzve8A= go.bug.st/serial v1.6.4/go.mod h1:nofMJxTeNVny/m6+KaafC6vJGj3miwQZ6vW4BZUGJPI= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= golang.org/x/arch v0.18.0 h1:WN9poc33zL4AzGxqf8VtpKUnGvMi8O9lhNyBMF/85qc= golang.org/x/arch v0.18.0/go.mod h1:bdwinDaKcfZUGpH09BB7ZmOfhalA8lQdzl62l8gGWsk= -golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM= -golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U= +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/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw= golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA= golang.org/x/oauth2 v0.30.0 h1:dnDm7JmhM45NNpd8FDDeLhK6FwqbOf4MLCM9zb1BOHI= golang.org/x/oauth2 v0.30.0/go.mod h1:B++QgG3ZKulg6sRPGD/mqlHQs5rB3Ml9erfeDY7xKlU= +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-20220622161953-175b2fd9d664/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw= -golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= -golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M= -golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA= +golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= +golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= +golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/internal/confparser/confparser.go b/internal/confparser/confparser.go index 6a65207..270c3e8 100644 --- a/internal/confparser/confparser.go +++ b/internal/confparser/confparser.go @@ -379,6 +379,18 @@ func (f *FieldConfig) validateSingleValue(val string, index int) error { } switch validateType { + case "int": + if _, err := strconv.Atoi(val); err != nil { + return fmt.Errorf("field `%s` is not a valid integer: %s", f.Name, val) + } + case "ipv6_prefix_length": + valInt, err := strconv.Atoi(val) + if err != nil { + return fmt.Errorf("field `%s` is not a valid IPv6 prefix length: %s", f.Name, val) + } + if valInt < 0 || valInt > 128 { + return fmt.Errorf("field `%s` is not a valid IPv6 prefix length: %s", f.Name, val) + } case "ipv4": if net.ParseIP(val).To4() == nil { return fmt.Errorf("%s is not a valid IPv4 address: %s", fieldRef, val) diff --git a/internal/confparser/confparser_test.go b/internal/confparser/confparser_test.go index e14a1ea..7e43f26 100644 --- a/internal/confparser/confparser_test.go +++ b/internal/confparser/confparser_test.go @@ -24,10 +24,10 @@ type testIPv4StaticConfig struct { } type testIPv6StaticConfig struct { - Address null.String `json:"address" validate_type:"ipv6" required:"true"` - Prefix null.String `json:"prefix" validate_type:"ipv6" required:"true"` - Gateway null.String `json:"gateway" validate_type:"ipv6" required:"true"` - DNS []string `json:"dns" validate_type:"ipv6" required:"true"` + Address null.String `json:"address" validate_type:"ipv6" required:"true"` + PrefixLength null.Int `json:"prefix_length" validate_type:"ipv6_prefix_length" required:"true"` + Gateway null.String `json:"gateway" validate_type:"ipv6" required:"true"` + DNS []string `json:"dns" validate_type:"ipv6" required:"true"` } type testNetworkConfig struct { Hostname null.String `json:"hostname,omitempty"` diff --git a/internal/dhclient/client.go b/internal/dhclient/client.go new file mode 100644 index 0000000..d5012d3 --- /dev/null +++ b/internal/dhclient/client.go @@ -0,0 +1,424 @@ +package dhclient + +import ( + "context" + "errors" + "fmt" + "net" + "slices" + "sync" + "time" + + "github.com/go-co-op/gocron/v2" + "github.com/insomniacslk/dhcp/dhcpv4" + "github.com/insomniacslk/dhcp/dhcpv6" + "github.com/jetkvm/kvm/internal/logging" + "github.com/rs/zerolog" + "github.com/vishvananda/netlink" +) + +const ( + VendorIdentifier = "jetkvm" +) + +var ( + logger = logging.GetSubsystemLogger("dhclient") + + ErrIPv6LinkTimeout = errors.New("timeout after waiting for a non-tentative IPv6 address") + ErrIPv6RouteTimeout = errors.New("timeout after waiting for an IPv6 route") + ErrInterfaceUpTimeout = errors.New("timeout after waiting for an interface to come up") + ErrInterfaceUpCanceled = errors.New("context canceled while waiting for an interface to come up") +) + +type LeaseChangeHandler func(lease *Lease) + +// Config is a DHCP client configuration. +type Config struct { + LinkUpTimeout time.Duration + + // Timeout is the timeout for one DHCP request attempt. + Timeout time.Duration + + // Retries is how many times to retry DHCP attempts. + Retries int + + // IPv4 is whether to request an IPv4 lease. + IPv4 bool + + // IPv6 is whether to request an IPv6 lease. + IPv6 bool + + // Modifiers4 allows modifications to the IPv4 DHCP request. + Modifiers4 []dhcpv4.Modifier + + // Modifiers6 allows modifications to the IPv6 DHCP request. + Modifiers6 []dhcpv6.Modifier + + // V6ServerAddr can be a unicast or broadcast destination for DHCPv6 + // messages. + // + // If not set, it will default to nclient6's default (all servers & + // relay agents). + V6ServerAddr *net.UDPAddr + + // V6ClientPort is the port that is used to send and receive DHCPv6 + // messages. + // + // If not set, it will default to dhcpv6's default (546). + V6ClientPort *int + + // V4ServerAddr can be a unicast or broadcast destination for IPv4 DHCP + // messages. + // + // If not set, it will default to nclient4's default (DHCP broadcast + // address). + V4ServerAddr *net.UDPAddr + + // If true, add Client Identifier (61) option to the IPv4 request. + V4ClientIdentifier bool + + OnLease4Change LeaseChangeHandler + OnLease6Change LeaseChangeHandler +} + +type Client struct { + ifaces []netlink.Link + cfg Config + l *zerolog.Logger + + ctx context.Context + + // TODO: support multiple interfaces + currentLease4 *Lease + currentLease6 *Lease + + mu sync.Mutex + cfgMu sync.Mutex + + lease4Mu sync.Mutex + lease6Mu sync.Mutex + + scheduler gocron.Scheduler +} + +// NewClient creates a new DHCP client for the given interface. +func NewClient(ctx context.Context, ifaces []netlink.Link, c *Config, l *zerolog.Logger) (*Client, error) { + scheduler, err := gocron.NewScheduler() + if err != nil { + return nil, fmt.Errorf("failed to create scheduler: %w", err) + } + + cfg := *c + if cfg.LinkUpTimeout == 0 { + cfg.LinkUpTimeout = 30 * time.Second + } + + if cfg.Timeout == 0 { + cfg.Timeout = 30 * time.Second + } + + if cfg.Retries == 0 { + cfg.Retries = 3 + } + + return &Client{ + ctx: ctx, + ifaces: ifaces, + cfg: cfg, + l: l, + scheduler: scheduler, + + currentLease4: nil, + currentLease6: nil, + + lease4Mu: sync.Mutex{}, + lease6Mu: sync.Mutex{}, + + mu: sync.Mutex{}, + cfgMu: sync.Mutex{}, + }, nil +} + +func (c *Client) ensureInterfaceUp(iface netlink.Link) (netlink.Link, error) { + ifname := iface.Attrs().Name + + l := c.l.With().Str("interface", ifname).Logger() + + linkUpTimeout := time.After(c.cfg.LinkUpTimeout) + + for { + link, err := netlink.LinkByName(ifname) + if err != nil { + return nil, err + } + + state := link.Attrs().OperState + if state == netlink.OperUp || state == netlink.OperUnknown { + return link, nil + } + + l.Info().Interface("state", state).Msg("bringing up interface") + + if err = netlink.LinkSetUp(link); err != nil { + l.Error().Err(err).Msg("interface can't make it up") + } + + select { + case <-time.After(100 * time.Millisecond): + continue + case <-c.ctx.Done(): + if err != nil { + return nil, err + } + return nil, ErrInterfaceUpCanceled + case <-linkUpTimeout: + l.Error().Msg("interface is still down after timeout") + if err != nil { + return nil, err + } + return nil, ErrInterfaceUpTimeout + } + } +} + +func (c *Client) sendInitialRequests() chan interface{} { + return c.sendRequests(c.cfg.IPv4, c.cfg.IPv6) +} + +func (c *Client) sendRequests(ipv4, ipv6 bool) chan interface{} { + c.mu.Lock() + defer c.mu.Unlock() + + // Yeah, this is a hack, until we can cancel all leases in progress. + r := make(chan interface{}, 3*len(c.ifaces)) + + var wg sync.WaitGroup + for _, iface := range c.ifaces { + wg.Add(1) + go func(iface netlink.Link) { + defer wg.Done() + + ifname := iface.Attrs().Name + l := c.l.With().Str("interface", ifname).Logger() + + iface, err := c.ensureInterfaceUp(iface) + if err != nil { + l.Error().Err(err).Msg("Could not bring up interface") + return + } + + if ipv4 { + wg.Add(1) + go func(iface netlink.Link) { + defer wg.Done() + lease, err := c.requestLease4(iface) + if err != nil { + l.Error().Err(err).Msg("Could not get IPv4 lease") + return + } + r <- lease + }(iface) + } + + if ipv6 { + return // TODO: implement DHCP6 + wg.Add(1) + go func(iface netlink.Link) { + defer wg.Done() + lease, err := c.requestLease6(iface) + if err != nil { + l.Error().Err(err).Msg("Could not get IPv6 lease") + return + } + r <- lease + }(iface) + } + }(iface) + } + + go func() { + wg.Wait() + close(r) + }() + return r +} + +func (c *Client) Lease4() *Lease { + c.lease4Mu.Lock() + defer c.lease4Mu.Unlock() + + return c.currentLease4 +} + +func (c *Client) Lease6() *Lease { + c.lease6Mu.Lock() + defer c.lease6Mu.Unlock() + + return c.currentLease6 +} + +func (c *Client) Domain() string { + c.lease4Mu.Lock() + defer c.lease4Mu.Unlock() + + if c.currentLease4 != nil { + return c.currentLease4.Domain + } + + c.lease6Mu.Lock() + defer c.lease6Mu.Unlock() + + if c.currentLease6 != nil { + return c.currentLease6.Domain + } + + return "" +} + +func (c *Client) handleLeaseChange(lease *Lease) { + // do not use defer here, because we need to unlock the mutex before returning + + ipv4 := lease.p4 != nil + version := "ipv4" + + if ipv4 { + c.lease4Mu.Lock() + c.currentLease4 = lease + } else { + version = "ipv6" + c.lease6Mu.Lock() + c.currentLease6 = lease + } + + // clear all current jobs with the same tags + c.scheduler.RemoveByTags(version) + + // add scheduler job to renew the lease + if lease.RenewalTime > 0 { + c.scheduler.NewJob( + gocron.DurationJob(lease.RenewalTime), + gocron.NewTask(func() { + c.l.Info().Msg("renewing lease") + for lease := range c.sendRequests(ipv4, !ipv4) { + if lease, ok := lease.(*Lease); ok { + c.handleLeaseChange(lease) + } + } + }), + gocron.WithName(fmt.Sprintf("renew-%s", version)), + gocron.WithSingletonMode(gocron.LimitModeWait), + gocron.WithTags(version), + ) + } + + c.apply() + + if ipv4 { + c.lease4Mu.Unlock() + } else { + c.lease6Mu.Unlock() + } + + // TODO: handle lease expiration + if c.cfg.OnLease4Change != nil && ipv4 { + c.cfg.OnLease4Change(lease) + } + + if c.cfg.OnLease6Change != nil && !ipv4 { + c.cfg.OnLease6Change(lease) + } +} + +func (c *Client) renew() { + for lease := range c.sendRequests(c.cfg.IPv4, c.cfg.IPv6) { + if lease, ok := lease.(*Lease); ok { + c.handleLeaseChange(lease) + } + } +} + +func (c *Client) Renew() { + go c.renew() +} + +func (c *Client) Release() { + // TODO: implement +} + +func (c *Client) SetIPv4(ipv4 bool) { + c.cfgMu.Lock() + defer c.cfgMu.Unlock() + + currentIPv4 := c.cfg.IPv4 + c.cfg.IPv4 = ipv4 + + if !ipv4 { + c.lease4Mu.Lock() + c.currentLease4 = nil + c.lease4Mu.Unlock() + c.scheduler.RemoveByTags("ipv4") + } + + if currentIPv4 || ipv4 { + // TODO: send initial requests + } +} + +func (c *Client) SetIPv6(ipv6 bool) { + c.cfg.IPv6 = ipv6 +} + +func (c *Client) Start() error { + if err := c.killUdhcpc(); err != nil { + c.l.Warn().Err(err).Msg("failed to kill udhcpc processes, continuing anyway") + } + + c.scheduler.Start() + + go func() { + for lease := range c.sendInitialRequests() { + if lease, ok := lease.(*Lease); ok { + c.handleLeaseChange(lease) + } + } + }() + + return nil +} + +func (c *Client) apply() { + var ( + iface string + nameservers []net.IP + searchList []string + domain string + ) + + if c.currentLease4 != nil { + iface = c.currentLease4.InterfaceName + nameservers = c.currentLease4.DNS + searchList = c.currentLease4.SearchList + domain = c.currentLease4.Domain + } + + if c.currentLease6 != nil { + iface = c.currentLease6.InterfaceName + nameservers = append(nameservers, c.currentLease6.DNS...) + searchList = append(searchList, c.currentLease6.SearchList...) + domain = c.currentLease6.Domain + } + + // deduplicate searchList + searchList = slices.Compact(searchList) + + c.l.Info(). + Str("interface", iface). + Interface("nameservers", nameservers). + Interface("searchList", searchList). + Str("domain", domain). + Msg("updating resolv.conf") + + if err := updateResolvConf(iface, nameservers, searchList, domain); err != nil { + c.l.Error().Err(err).Msg("failed to update resolv.conf") + } +} diff --git a/internal/dhclient/dhcp4.go b/internal/dhclient/dhcp4.go new file mode 100644 index 0000000..9518bc8 --- /dev/null +++ b/internal/dhclient/dhcp4.go @@ -0,0 +1,52 @@ +package dhclient + +import ( + "github.com/insomniacslk/dhcp/dhcpv4" + "github.com/insomniacslk/dhcp/dhcpv4/nclient4" + "github.com/vishvananda/netlink" +) + +func (c *Client) requestLease4(iface netlink.Link) (*Lease, error) { + ifname := iface.Attrs().Name + l := c.l.With().Str("interface", ifname).Logger() + + mods := []nclient4.ClientOpt{ + nclient4.WithTimeout(c.cfg.Timeout), + nclient4.WithRetry(c.cfg.Retries), + } + mods = append(mods, c.getDHCP4Logger(ifname)) + if c.cfg.V4ServerAddr != nil { + mods = append(mods, nclient4.WithServerAddr(c.cfg.V4ServerAddr)) + } + + client, err := nclient4.New(ifname, mods...) + if err != nil { + return nil, err + } + defer client.Close() + + // Prepend modifiers with default options, so they can be overriden. + reqmods := append( + []dhcpv4.Modifier{ + dhcpv4.WithOption(dhcpv4.OptClassIdentifier(VendorIdentifier)), + dhcpv4.WithRequestedOptions(dhcpv4.OptionSubnetMask), + }, + c.cfg.Modifiers4...) + + if c.cfg.V4ClientIdentifier { + // Client Id is hardware type + mac per RFC 2132 9.14. + ident := []byte{0x01} // Type ethernet + ident = append(ident, iface.Attrs().HardwareAddr...) + reqmods = append(reqmods, dhcpv4.WithOption(dhcpv4.OptClientIdentifier(ident))) + } + + l.Info().Msg("attempting to get DHCPv4 lease") + lease, err := client.Request(c.ctx, reqmods...) + if err != nil { + return nil, err + } + + l.Info().Msgf("DHCPv4 lease acquired: %s", lease.ACK.Summary()) + + return fromNclient4Lease(lease, ifname), nil +} diff --git a/internal/dhclient/dhcp6.go b/internal/dhclient/dhcp6.go new file mode 100644 index 0000000..005d1ab --- /dev/null +++ b/internal/dhclient/dhcp6.go @@ -0,0 +1,132 @@ +package dhclient + +import ( + "log" + "net" + "time" + + "github.com/insomniacslk/dhcp/dhcpv6" + "github.com/insomniacslk/dhcp/dhcpv6/nclient6" + "github.com/vishvananda/netlink" + "golang.org/x/sys/unix" +) + +// isIPv6LinkReady returns true if the interface has a link-local address +// which is not tentative. +func isIPv6LinkReady(l netlink.Link) (bool, error) { + addrs, err := netlink.AddrList(l, netlink.FAMILY_V6) + if err != nil { + return false, err + } + for _, addr := range addrs { + if addr.IP.IsLinkLocalUnicast() && (addr.Flags&unix.IFA_F_TENTATIVE == 0) { + if addr.Flags&unix.IFA_F_DADFAILED != 0 { + log.Printf("DADFAILED for %v, continuing anyhow", addr.IP) + } + return true, nil + } + } + return false, nil +} + +// isIPv6RouteReady returns true if serverAddr is reachable. +func isIPv6RouteReady(serverAddr net.IP) waitForCondition { + return func(l netlink.Link) (bool, error) { + if serverAddr.IsMulticast() { + return true, nil + } + + routes, err := netlink.RouteList(l, netlink.FAMILY_V6) + if err != nil { + return false, err + } + for _, route := range routes { + if route.LinkIndex != l.Attrs().Index { + continue + } + // Default route. + if route.Dst == nil { + return true, nil + } + if route.Dst.Contains(serverAddr) { + return true, nil + } + } + return false, nil + } +} + +func (c *Client) requestLease6(iface netlink.Link) (*Lease, error) { + ifname := iface.Attrs().Name + l := c.l.With().Str("interface", ifname).Logger() + + clientPort := dhcpv6.DefaultClientPort + if c.cfg.V6ClientPort != nil { + clientPort = *c.cfg.V6ClientPort + } + + // For ipv6, we cannot bind to the port until Duplicate Address + // Detection (DAD) is complete which is indicated by the link being no + // longer marked as "tentative". This usually takes about a second. + + // If the link is never going to be ready, don't wait forever. + // (The user may not have configured a ctx with a timeout.) + + linkUpTimeout := time.After(c.cfg.LinkUpTimeout) + if err := c.waitFor( + iface, + linkUpTimeout, + isIPv6LinkReady, + ErrIPv6LinkTimeout, + ); err != nil { + return nil, err + } + + // If user specified a non-multicast address, make sure it's routable before we start. + if c.cfg.V6ServerAddr != nil { + if err := c.waitFor( + iface, + linkUpTimeout, + isIPv6RouteReady(c.cfg.V6ServerAddr.IP), + ErrIPv6RouteTimeout, + ); err != nil { + return nil, err + } + } + + mods := []nclient6.ClientOpt{ + nclient6.WithTimeout(c.cfg.Timeout), + nclient6.WithRetry(c.cfg.Retries), + c.getDHCP6Logger(), + } + if c.cfg.V6ServerAddr != nil { + mods = append(mods, nclient6.WithBroadcastAddr(c.cfg.V6ServerAddr)) + } + + conn, err := nclient6.NewIPv6UDPConn(iface.Attrs().Name, clientPort) + if err != nil { + return nil, err + } + + client, err := nclient6.NewWithConn(conn, iface.Attrs().HardwareAddr, mods...) + if err != nil { + return nil, err + } + defer client.Close() + + // Prepend modifiers with default options, so they can be overriden. + reqmods := append( + []dhcpv6.Modifier{ + dhcpv6.WithNetboot, + }, + c.cfg.Modifiers6...) + + l.Info().Msg("attempting to get DHCPv6 lease") + p, err := client.RapidSolicit(c.ctx, reqmods...) + if err != nil { + return nil, err + } + + l.Info().Msgf("DHCPv6 lease acquired: %s", p.Summary()) + return fromNclient6Lease(p, ifname), nil +} diff --git a/internal/udhcpc/parser.go b/internal/dhclient/lease.go similarity index 64% rename from internal/udhcpc/parser.go rename to internal/dhclient/lease.go index 66c3ba2..a2d5f3b 100644 --- a/internal/udhcpc/parser.go +++ b/internal/dhclient/lease.go @@ -1,4 +1,4 @@ -package udhcpc +package dhclient import ( "bufio" @@ -10,8 +10,17 @@ import ( "strconv" "strings" "time" + + "github.com/insomniacslk/dhcp/dhcpv4/nclient4" + "github.com/insomniacslk/dhcp/dhcpv6" ) +var ( + defaultLeaseTime = time.Duration(30 * time.Minute) + defaultRenewalTime = time.Duration(15 * time.Minute) +) + +// Lease is a network configuration obtained by DHCP. type Lease struct { // from https://udhcp.busybox.net/README.udhcpc IPAddress net.IP `env:"ip" json:"ip"` // The obtained IP @@ -21,6 +30,7 @@ type Lease struct { MTU int `env:"mtu" json:"mtu,omitempty"` // The MTU to use for this network HostName string `env:"hostname" json:"hostname,omitempty"` // The assigned hostname Domain string `env:"domain" json:"domain,omitempty"` // The domain name of the network + SearchList []string `env:"search" json:"search_list,omitempty"` // The search list for the network BootPNextServer net.IP `env:"siaddr" json:"bootp_next_server,omitempty"` // The bootp next server option BootPServerName string `env:"sname" json:"bootp_server_name,omitempty"` // The bootp server name option BootPFile string `env:"boot_file" json:"bootp_file,omitempty"` // The bootp boot file option @@ -38,24 +48,100 @@ type Lease struct { BootSize int `env:"bootsize" json:"bootsize,omitempty"` // The length in 512 octect blocks of the bootfile RootPath string `env:"rootpath" json:"root_path,omitempty"` // The path name of the client's root disk LeaseTime time.Duration `env:"lease" json:"lease,omitempty"` // The lease time, in seconds + RenewalTime time.Duration `env:"renewal" json:"renewal,omitempty"` // The renewal time, in seconds + RebindingTime time.Duration `env:"rebinding" json:"rebinding,omitempty"` // The rebinding time, in seconds DHCPType string `env:"dhcptype" json:"dhcp_type,omitempty"` // DHCP message type (safely ignored) ServerID string `env:"serverid" json:"server_id,omitempty"` // The IP of the server Message string `env:"message" json:"reason,omitempty"` // Reason for a DHCPNAK TFTPServerName string `env:"tftp" json:"tftp,omitempty"` // The TFTP server name BootFileName string `env:"bootfile" json:"bootfile,omitempty"` // The boot file name Uptime time.Duration `env:"uptime" json:"uptime,omitempty"` // The uptime of the device when the lease was obtained, in seconds + ClassIdentifier string `env:"classid" json:"class_identifier,omitempty"` // The class identifier LeaseExpiry *time.Time `json:"lease_expiry,omitempty"` // The expiry time of the lease - isEmpty map[string]bool + + InterfaceName string `json:"interface_name,omitempty"` // The name of the interface + + p4 *nclient4.Lease + p6 *dhcpv6.Message + + isEmpty map[string]bool +} + +// fromNclient4Lease creates a lease from a nclient4.Lease. +func fromNclient4Lease(l *nclient4.Lease, iface string) *Lease { + lease := &Lease{} + + lease.p4 = l + + // only the fields that we need are set + lease.Routers = l.ACK.Router() + lease.IPAddress = l.ACK.YourIPAddr + lease.Netmask = net.IP(l.ACK.SubnetMask()) + lease.Broadcast = l.ACK.BroadcastAddress() + // lease.MTU = int(resp.Options.Get(dhcpv4.OptionInterfaceMTU)) + + lease.NTPServers = l.ACK.NTPServers() + + lease.HostName = l.ACK.HostName() + lease.Domain = l.ACK.DomainName() + + searchList := l.ACK.DomainSearch() + if searchList != nil { + lease.SearchList = searchList.Labels + } + + lease.DNS = l.ACK.DNS() + + lease.ClassIdentifier = l.ACK.ClassIdentifier() + lease.ServerID = l.ACK.ServerIdentifier().String() + + lease.Message = l.ACK.Message() + lease.LeaseTime = l.ACK.IPAddressLeaseTime(defaultLeaseTime) + lease.RenewalTime = l.ACK.IPAddressRenewalTime(defaultRenewalTime) + + lease.InterfaceName = iface + + return lease +} + +// fromNclient6Lease creates a lease from a nclient6.Message. +func fromNclient6Lease(l *dhcpv6.Message, iface string) *Lease { + lease := &Lease{} + + lease.p6 = l + + iana := l.Options.OneIANA() + if iana == nil { + return nil + } + + address := iana.Options.OneAddress() + if address == nil { + return nil + } + + lease.IPAddress = address.IPv6Addr + lease.Netmask = net.IP(net.CIDRMask(128, 128)) + lease.DNS = l.Options.DNS() + // lease.LeaseTime = iana.Options.OnePreferredLifetime() + // lease.RenewalTime = iana.Options.OneValidLifetime() + // lease.RebindingTime = iana.Options.OneRebindingTime() + + lease.InterfaceName = iface + + return lease } func (l *Lease) setIsEmpty(m map[string]bool) { l.isEmpty = m } +// IsEmpty returns true if the lease is empty for the given key. func (l *Lease) IsEmpty(key string) bool { return l.isEmpty[key] } +// ToJSON returns the lease as a JSON string. func (l *Lease) ToJSON() string { json, err := json.Marshal(l) if err != nil { @@ -64,13 +150,13 @@ func (l *Lease) ToJSON() string { return string(json) } +// SetLeaseExpiry sets the lease expiry time. func (l *Lease) SetLeaseExpiry() (time.Time, error) { if l.Uptime == 0 || l.LeaseTime == 0 { return time.Time{}, fmt.Errorf("uptime or lease time isn't set") } // get the uptime of the device - file, err := os.Open("/proc/uptime") if err != nil { return time.Time{}, fmt.Errorf("failed to open uptime file: %w", err) @@ -98,6 +184,7 @@ func (l *Lease) SetLeaseExpiry() (time.Time, error) { return leaseExpiry, nil } +// UnmarshalDHCPCLease unmarshals a lease from a string. func UnmarshalDHCPCLease(lease *Lease, str string) error { // parse the lease file as a map data := make(map[string]string) @@ -184,3 +271,45 @@ func UnmarshalDHCPCLease(lease *Lease, str string) error { return nil } + +// MarshalDHCPCLease marshals a lease to a string. +func MarshalDHCPCLease(lease *Lease) (string, error) { + leaseType := reflect.TypeOf(lease).Elem() + leaseValue := reflect.ValueOf(lease).Elem() + + leaseFile := "" + + for i := 0; i < leaseType.NumField(); i++ { + field := leaseValue.Field(i) + key := leaseType.Field(i).Tag.Get("env") + if key == "" { + continue + } + + outValue := "" + + switch field.Interface().(type) { + case string: + outValue = field.String() + case int: + outValue = strconv.Itoa(int(field.Int())) + case time.Duration: + outValue = strconv.Itoa(int(field.Int())) + case net.IP: + outValue = field.String() + case []net.IP: + ips := field.Interface().([]net.IP) + ipStrings := make([]string, len(ips)) + for i, ip := range ips { + ipStrings[i] = ip.String() + } + outValue = strings.Join(ipStrings, " ") + default: + return "", fmt.Errorf("unsupported field `%s` type: %s", key, field.Type().String()) + } + + leaseFile += fmt.Sprintf("%s=%s\n", key, outValue) + } + + return leaseFile, nil +} diff --git a/internal/dhclient/legacy.go b/internal/dhclient/legacy.go new file mode 100644 index 0000000..2a47a70 --- /dev/null +++ b/internal/dhclient/legacy.go @@ -0,0 +1,95 @@ +package dhclient + +import ( + "bytes" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" +) + +func readFileNoStat(filename string) ([]byte, error) { + const maxBufferSize = 1024 * 1024 + + f, err := os.Open(filename) + if err != nil { + return nil, err + } + defer f.Close() + + reader := io.LimitReader(f, maxBufferSize) + return io.ReadAll(reader) +} + +func toCmdline(path string) ([]string, error) { + data, err := readFileNoStat(path) + if err != nil { + return nil, err + } + + if len(data) < 1 { + return []string{}, nil + } + + return strings.Split(string(bytes.TrimRight(data, "\x00")), "\x00"), nil +} + +func (c *Client) killUdhcpc() error { + // read procfs for udhcpc processes + // we do not use procfs.AllProcs() because we want to avoid the overhead of reading the entire procfs + processes, err := os.ReadDir("/proc") + if err != nil { + return err + } + + matchedPids := make([]int, 0) + + // iterate over the processes + for _, d := range processes { + // check if file is numeric + pid, err := strconv.Atoi(d.Name()) + if err != nil { + continue + } + + // check if it's a directory + if !d.IsDir() { + continue + } + + cmdline, err := toCmdline(filepath.Join("/proc", d.Name(), "cmdline")) + if err != nil { + continue + } + + if len(cmdline) < 1 { + continue + } + + if cmdline[0] != "udhcpc" { + continue + } + + matchedPids = append(matchedPids, pid) + } + + if len(matchedPids) == 0 { + c.l.Info().Msg("no udhcpc processes found") + return nil + } + + c.l.Info().Ints("pids", matchedPids).Msg("found udhcpc processes, terminating") + + for _, pid := range matchedPids { + err := syscall.Kill(pid, syscall.SIGTERM) + if err != nil { + return err + } + + c.l.Info().Int("pid", pid).Msg("terminated udhcpc process") + } + + return nil +} diff --git a/internal/dhclient/logging.go b/internal/dhclient/logging.go new file mode 100644 index 0000000..3d6cb8c --- /dev/null +++ b/internal/dhclient/logging.go @@ -0,0 +1,40 @@ +package dhclient + +import ( + "github.com/insomniacslk/dhcp/dhcpv4" + "github.com/insomniacslk/dhcp/dhcpv4/nclient4" + "github.com/insomniacslk/dhcp/dhcpv6/nclient6" + "github.com/rs/zerolog" +) + +type dhcpLogger struct { + // Printfer is used for actual output of the logger + nclient4.Printfer + + l *zerolog.Logger +} + +// Printf prints a log message as-is via predefined Printfer +func (s dhcpLogger) Printf(format string, v ...interface{}) { + s.l.Info().Msgf(format, v...) +} + +// PrintMessage prints a DHCP message in the short format via predefined Printfer +func (s dhcpLogger) PrintMessage(prefix string, message *dhcpv4.DHCPv4) { + s.l.Info().Str("prefix", prefix).Str("message", message.String()).Msg("DHCP message") +} + +func (c *Client) getDHCP4Logger(ifname string) nclient4.ClientOpt { + logger := c.l.With().Str("interface", ifname).Logger() + + return nclient4.WithLogger(dhcpLogger{ + l: &logger, + }) +} + +// TODO: nclient6 doesn't implement the WithLogger option, +// we might need to open a PR to add it + +func (c *Client) getDHCP6Logger() nclient6.ClientOpt { + return nclient6.WithSummaryLogger() +} diff --git a/internal/dhclient/resolvconf.go b/internal/dhclient/resolvconf.go new file mode 100644 index 0000000..8c2246d --- /dev/null +++ b/internal/dhclient/resolvconf.go @@ -0,0 +1,62 @@ +package dhclient + +import ( + "bytes" + "fmt" + "html/template" + "net" + "os" + "strings" +) + +const ( + resolvConfPath = "/etc/resolv.conf" + resolvConfFileMode = 0644 + resolvConfTemplate = `# the resolv.conf file is managed by the jetkvm-dhclient service +# DO NOT EDIT THIS FILE BY HAND -- YOUR CHANGES WILL BE OVERWRITTEN + +{{ if .searchList }} +search {{ join .searchList " " }} # {{ .iface }} +{{- end -}} +{{ if .domain }} +domain {{ .domain }} # {{ .iface }} +{{- end -}} +{{ range .nameservers }} +nameserver {{ printf "%s" . }} # {{ $.iface }} +{{- end }} +` +) + +var ( + tplFuncMap = template.FuncMap{ + "join": strings.Join, + } +) + +func toResolvConf(iface string, nameservers []net.IP, searchList []string, domain string) (bytes.Buffer, error) { + rc := bytes.Buffer{} + tmpl, err := template.New("resolv.conf").Funcs(tplFuncMap).Parse(resolvConfTemplate) + if err != nil { + return rc, fmt.Errorf("failed to parse template: %w", err) + } + + if err := tmpl.Execute(&rc, map[string]interface{}{ + "iface": iface, + "nameservers": nameservers, + "searchList": searchList, + "domain": domain, + }); err != nil { + return rc, fmt.Errorf("failed to execute template: %w", err) + } + + return rc, nil +} + +func updateResolvConf(iface string, nameservers []net.IP, searchList []string, domain string) error { + rc, err := toResolvConf(iface, nameservers, searchList, domain) + if err != nil { + return err + } + + return os.WriteFile(resolvConfPath, rc.Bytes(), resolvConfFileMode) +} diff --git a/internal/dhclient/resolvconf_test.go b/internal/dhclient/resolvconf_test.go new file mode 100644 index 0000000..9c2f62d --- /dev/null +++ b/internal/dhclient/resolvconf_test.go @@ -0,0 +1,35 @@ +package dhclient + +import ( + "net" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestToResolvConf(t *testing.T) { + rc, err := toResolvConf( + "eth0", + []net.IP{ + net.ParseIP("198.51.100.53"), + net.ParseIP("203.0.113.53"), + }, + []string{"example.com"}, + "example.com", + ) + if err != nil { + t.Fatal(err) + } + + want := `# the resolv.conf file is managed by the jetkvm-dhclient service +# DO NOT EDIT THIS FILE BY HAND -- YOUR CHANGES WILL BE OVERWRITTEN + + +search example.com # eth0 +domain example.com # eth0 +nameserver 198.51.100.53 # eth0 +nameserver 203.0.113.53 # eth0 +` + + assert.Equal(t, want, rc.String()) +} diff --git a/internal/dhclient/utils.go b/internal/dhclient/utils.go new file mode 100644 index 0000000..4825cc4 --- /dev/null +++ b/internal/dhclient/utils.go @@ -0,0 +1,46 @@ +package dhclient + +import ( + "context" + "time" + + "github.com/vishvananda/netlink" +) + +type waitForCondition func(l netlink.Link) (ready bool, err error) + +func (c *Client) waitFor( + link netlink.Link, + timeout <-chan time.Time, + condition waitForCondition, + timeoutError error, +) error { + return waitFor(c.ctx, link, timeout, condition, timeoutError) +} + +func waitFor( + ctx context.Context, + link netlink.Link, + timeout <-chan time.Time, + condition waitForCondition, + timeoutError error, +) error { + for { + if ready, err := condition(link); err != nil { + return err + } else if ready { + break + } + + select { + case <-time.After(100 * time.Millisecond): + continue + case <-timeout: + return timeoutError + case <-ctx.Done(): + return timeoutError + } + } + + return nil +} diff --git a/internal/network/config.go b/internal/network/config.go index 6f0669a..fe5ebd1 100644 --- a/internal/network/config.go +++ b/internal/network/config.go @@ -28,9 +28,10 @@ type IPv4StaticConfig struct { } type IPv6StaticConfig struct { - Address null.String `json:"address,omitempty" validate_type:"cidr" required:"true"` - Gateway null.String `json:"gateway,omitempty" validate_type:"ipv6" required:"true"` - DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"` + Address null.String `json:"address,omitempty" validate_type:"ipv6" required:"true"` + PrefixLength null.Int `json:"prefix_length" validate_type:"ipv6_prefix_length" required:"true"` + Gateway null.String `json:"gateway,omitempty" validate_type:"ipv6" required:"true"` + DNS []string `json:"dns,omitempty" validate_type:"ipv6" required:"true"` } type NetworkConfig struct { Hostname null.String `json:"hostname,omitempty" validate_type:"hostname"` @@ -108,7 +109,7 @@ func (s *NetworkInterfaceState) GetDomain() string { domain := ToValidDomain(s.config.Domain.String) if domain == "" { - lease := s.dhcpClient.GetLease() + lease := s.dhcpClient.Lease4() if lease != nil && lease.Domain != "" { domain = ToValidDomain(lease.Domain) } diff --git a/internal/network/netif.go b/internal/network/netif.go index 5a8dab6..a011cf9 100644 --- a/internal/network/netif.go +++ b/internal/network/netif.go @@ -1,13 +1,14 @@ package network import ( + "context" "fmt" "net" "sync" "github.com/jetkvm/kvm/internal/confparser" + "github.com/jetkvm/kvm/internal/dhclient" "github.com/jetkvm/kvm/internal/logging" - "github.com/jetkvm/kvm/internal/udhcpc" "github.com/rs/zerolog" "github.com/vishvananda/netlink" @@ -28,7 +29,8 @@ type NetworkInterfaceState struct { stateLock sync.Mutex config *NetworkConfig - dhcpClient *udhcpc.DHCPClient + ifConfig *NetworkInterfaceConfig + dhcpClient *dhclient.Client defaultHostname string currentHostname string @@ -48,7 +50,7 @@ type NetworkInterfaceOptions struct { DefaultHostname string OnStateChange func(state *NetworkInterfaceState) OnInitialCheck func(state *NetworkInterfaceState) - OnDhcpLeaseChange func(lease *udhcpc.Lease) + OnDhcpLeaseChange func(lease *dhclient.Lease) OnConfigChange func(config *NetworkConfig) NetworkConfig *NetworkConfig } @@ -80,12 +82,23 @@ func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceS ntpAddresses: make([]*net.IP, 0), } + ifConfig, err := NewNetworkInterfaceConfig(opts.InterfaceName, opts.NetworkConfig, opts.Logger) + if err != nil { + return nil, err + } + + link, err := netlink.LinkByName(opts.InterfaceName) + if err != nil { + return nil, err + } + + ctx := context.Background() + // create the dhcp client - dhcpClient := udhcpc.NewDHCPClient(&udhcpc.DHCPClientOptions{ - InterfaceName: opts.InterfaceName, - PidFile: opts.DhcpPidFile, - Logger: l, - OnLeaseChange: func(lease *udhcpc.Lease) { + dhcpClient, err := dhclient.NewClient(ctx, []netlink.Link{link}, &dhclient.Config{ + IPv4: true, + IPv6: true, + OnLease4Change: func(lease *dhclient.Lease) { _, err := s.update() if err != nil { opts.Logger.Error().Err(err).Msg("failed to update network state") @@ -96,9 +109,16 @@ func NewNetworkInterfaceState(opts *NetworkInterfaceOptions) (*NetworkInterfaceS opts.OnDhcpLeaseChange(lease) }, - }) + OnLease6Change: func(lease *dhclient.Lease) { + // NOT IMPLEMENTED + }, + }, l) + if err != nil { + return nil, err + } s.dhcpClient = dhcpClient + s.ifConfig = ifConfig return s, nil } @@ -341,7 +361,7 @@ func (s *NetworkInterfaceState) update() (DhcpTargetState, error) { return dhcpTargetState, nil } -func (s *NetworkInterfaceState) updateNtpServersFromLease(lease *udhcpc.Lease) error { +func (s *NetworkInterfaceState) updateNtpServersFromLease(lease *dhclient.Lease) error { if lease != nil && len(lease.NTPServers) > 0 { s.l.Info().Msg("lease found, updating DHCP NTP addresses") s.ntpAddresses = make([]*net.IP, 0, len(lease.NTPServers)) @@ -369,10 +389,10 @@ func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error { switch dhcpTargetState { case DhcpTargetStateRenew: s.l.Info().Msg("renewing DHCP lease") - _ = s.dhcpClient.Renew() + s.dhcpClient.Renew() case DhcpTargetStateRelease: s.l.Info().Msg("releasing DHCP lease") - _ = s.dhcpClient.Release() + s.dhcpClient.Release() case DhcpTargetStateStart: s.l.Warn().Msg("dhcpTargetStateStart not implemented") case DhcpTargetStateStop: @@ -384,5 +404,10 @@ func (s *NetworkInterfaceState) CheckAndUpdateDhcp() error { func (s *NetworkInterfaceState) onConfigChange(config *NetworkConfig) { _ = s.setHostnameIfNotSame() + + if err := s.ifConfig.Apply(s); err != nil { + s.l.Error().Err(err).Msg("failed to apply network interface config") + } + s.cbConfigChange(config) } diff --git a/internal/network/netif_linux.go b/internal/network/netif_linux.go index ec057f1..2b5b215 100644 --- a/internal/network/netif_linux.go +++ b/internal/network/netif_linux.go @@ -28,12 +28,18 @@ func (s *NetworkInterfaceState) Run() error { _ = s.setHostnameIfNotSame() // run the dhcp client - go s.dhcpClient.Run() // nolint:errcheck + if err := s.dhcpClient.Start(); err != nil { + return err + } if err := s.CheckAndUpdateDhcp(); err != nil { return err } + if err := s.ifConfig.Apply(s); err != nil { + s.l.Error().Err(err).Msg("failed to apply network interface config") + } + go func() { ticker := time.NewTicker(1 * time.Second) defer ticker.Stop() diff --git a/internal/network/rpc.go b/internal/network/rpc.go index 32f34f5..b53bfec 100644 --- a/internal/network/rpc.go +++ b/internal/network/rpc.go @@ -5,7 +5,7 @@ import ( "time" "github.com/jetkvm/kvm/internal/confparser" - "github.com/jetkvm/kvm/internal/udhcpc" + "github.com/jetkvm/kvm/internal/dhclient" ) type RpcIPv6Address struct { @@ -23,7 +23,8 @@ type RpcNetworkState struct { IPv6LinkLocal string `json:"ipv6_link_local,omitempty"` IPv4Addresses []string `json:"ipv4_addresses,omitempty"` IPv6Addresses []RpcIPv6Address `json:"ipv6_addresses,omitempty"` - DHCPLease *udhcpc.Lease `json:"dhcp_lease,omitempty"` + DHCPLease4 *dhclient.Lease `json:"dhcp_lease,omitempty"` // name kept for backwards compatibility + DHCPLease6 *dhclient.Lease `json:"dhcp_lease6,omitempty"` } type RpcNetworkSettings struct { @@ -84,7 +85,8 @@ func (s *NetworkInterfaceState) RpcGetNetworkState() RpcNetworkState { IPv6LinkLocal: s.IPv6LinkLocalAddress(), IPv4Addresses: s.ipv4Addresses, IPv6Addresses: ipv6Addresses, - DHCPLease: s.dhcpClient.GetLease(), + DHCPLease4: s.dhcpClient.Lease4(), + DHCPLease6: s.dhcpClient.Lease6(), } } @@ -111,6 +113,14 @@ func (s *NetworkInterfaceState) RpcSetNetworkSettings(settings RpcNetworkSetting return nil } + if err := validateIPv4Config(&settings.NetworkConfig); err != nil { + return err + } + + if err := validateIPv6Config(&settings.NetworkConfig); err != nil { + return err + } + s.config = &settings.NetworkConfig s.onConfigChange(s.config) @@ -122,5 +132,7 @@ func (s *NetworkInterfaceState) RpcRenewDHCPLease() error { return fmt.Errorf("dhcp client not initialized") } - return s.dhcpClient.Renew() + s.dhcpClient.Renew() + + return nil } diff --git a/internal/network/static.go b/internal/network/static.go new file mode 100644 index 0000000..4fb9701 --- /dev/null +++ b/internal/network/static.go @@ -0,0 +1,367 @@ +package network + +import ( + "fmt" + "net" + "os" + "path" + "strconv" + "strings" + "sync" + + "github.com/jetkvm/kvm/internal/logging" + "github.com/rs/zerolog" + "github.com/vishvananda/netlink" + "github.com/vishvananda/netlink/nl" +) + +const defaultInterface = "eth0" + +const ( + AF_INET = nl.FAMILY_V4 + AF_INET6 = nl.FAMILY_V6 + + sysctlBase = "/proc/sys" + sysctlFileMode = 0640 +) + +type NetworkInterfaceConfig struct { + config *NetworkConfig + l *zerolog.Logger + + ifaceName string + iface *netlink.Link + + lock sync.Mutex + ipv4Lock sync.Mutex + ipv6Lock sync.Mutex +} + +var ( + ipv4DefaultRoute = net.IPNet{ + IP: net.IPv4zero, + Mask: net.CIDRMask(0, 0), + } + + ipv6DefaultRoute = net.IPNet{ + IP: net.IPv6zero, + Mask: net.CIDRMask(0, 0), + } +) + +// NewNetworkInterfaceConfig ... +func NewNetworkInterfaceConfig(ifaceName string, config *NetworkConfig, logger *zerolog.Logger) (*NetworkInterfaceConfig, error) { + if ifaceName == "" { + ifaceName = defaultInterface + } + + link, err := netlink.LinkByName(ifaceName) + if err != nil { + return nil, fmt.Errorf("failed to get link by name: %w", err) + } + + if config == nil { + return nil, fmt.Errorf("config is nil") + } + + if logger == nil { + logger = logging.GetSubsystemLogger("network") + } + + scopedLogger := logger.With().Str("iface", ifaceName).Logger() + + return &NetworkInterfaceConfig{ + config: config, + l: &scopedLogger, + ifaceName: ifaceName, + iface: &link, + lock: sync.Mutex{}, + ipv4Lock: sync.Mutex{}, + ipv6Lock: sync.Mutex{}, + }, nil +} + +func (c *NetworkInterfaceConfig) Apply(s *NetworkInterfaceState) error { + if err := c.applyIPv4(s); err != nil { + return err + } + + if err := c.applyIPv6(s); err != nil { + return err + } + + return nil +} + +func (c *NetworkInterfaceConfig) applyIPv4(s *NetworkInterfaceState) error { + switch c.config.IPv4Mode.String { + case "static": + return c.applyIPv4Static(s) + case "dhcp": + s.dhcpClient.SetIPv4(true) + return nil + case "disabled": + s.dhcpClient.SetIPv4(false) + return nil + default: + return fmt.Errorf("invalid IPv4 mode: %s", c.config.IPv4Mode.String) + } +} + +func (c *NetworkInterfaceConfig) applyIPv6(s *NetworkInterfaceState) error { + switch c.config.IPv6Mode.String { + case "static": + return c.applyIPv6Static(s) + case "dhcpv6": + return fmt.Errorf("not implemented") + case "slaac": + return c.applyIPv6Slaac() + case "slaac_and_dhcpv6": + return fmt.Errorf("not implemented") + case "link_local": + return c.applyIPv6LinkLocalOnly() + case "disabled": + return c.disableIPv6() + default: + return fmt.Errorf("invalid IPv6 mode: %s", c.config.IPv6Mode.String) + } +} + +func checkIfAddressIsSetOrReturnCurrent(iface *netlink.Link, address net.IP, family int) (bool, []*netlink.Addr) { + addr, err := netlink.AddrList(*iface, family) + if err != nil { + return false, nil + } + + hit := false + linkAddrs := make([]*netlink.Addr, 0) + + for _, a := range addr { + if a.IP.Equal(address) { + hit = true + continue + } + + // we don't want to delete link-local addresses + if family == AF_INET6 && a.IP.IsLinkLocalUnicast() { + continue + } + + linkAddrs = append(linkAddrs, &a) + } + + return hit, linkAddrs +} + +func reconcileLinkAddrs(iface *netlink.Link, family int, expectedAddr *net.IPNet, logger *zerolog.Logger) error { + // TODO: we need to check if the netmask is the same + hit, currentLinkAddrs := checkIfAddressIsSetOrReturnCurrent(iface, expectedAddr.IP, family) + if !hit { + logger.Info().Interface("ip", expectedAddr).Msg("adding address") + if err := netlink.AddrAdd(*iface, &netlink.Addr{ + IPNet: expectedAddr, + }); err != nil { + logger.Info().Interface("ip", expectedAddr).Msg("failed to set address") + return fmt.Errorf("failed to add address: %w", err) + } + } + + for _, addr := range currentLinkAddrs { + logger.Info().Interface("ip", addr.IP).Msg("deleting address") + if err := netlink.AddrDel(*iface, addr); err != nil { + return fmt.Errorf("failed to delete address: %w", err) + } + } + + return nil +} + +func checkIfDefaultRouteIsSet(gateway net.IP, family int) bool { + defaultRoute := ipv4DefaultRoute + if family == AF_INET6 { + defaultRoute = ipv6DefaultRoute + } + + routes, err := netlink.RouteListFiltered(family, &netlink.Route{Dst: &defaultRoute}, netlink.RT_FILTER_DST) + if err != nil { + return false + } + + for _, r := range routes { + if r.Dst.IP.Equal(defaultRoute.IP) && r.Gw.Equal(gateway) { + return true + } + } + return false +} + +func ensureInterfaceIsUp(iface *netlink.Link) error { + if (*iface).Attrs().OperState == netlink.OperUp { + return nil + } + + if err := netlink.LinkSetUp(*iface); err != nil { + return fmt.Errorf("failed to set interface up: %w", err) + } + + return nil +} + +func (c *NetworkInterfaceConfig) applyIPv4Static(s *NetworkInterfaceState) error { + c.ipv4Lock.Lock() + defer c.ipv4Lock.Unlock() + + config, err := parseAndValidateStaticIPv4Config(c.config.IPv4Static) + if err != nil { + return err + } + + if c.iface == nil { + return fmt.Errorf("interface handle is nil") + } + + // disable DHCPv4 + s.dhcpClient.SetIPv4(false) + + if err := ensureInterfaceIsUp(c.iface); err != nil { + return err + } + + if err := reconcileLinkAddrs(c.iface, AF_INET, &config.network, c.l); err != nil { + return err + } + + if !checkIfDefaultRouteIsSet(config.gateway, AF_INET) { + c.l.Info().Str("iface", c.ifaceName).Interface("gateway", config.gateway).Msg("adding default route") + // TODO: support point-to-point + if err := netlink.RouteReplace(&netlink.Route{ + Dst: &ipv4DefaultRoute, + Gw: config.gateway, + LinkIndex: (*c.iface).Attrs().Index, + }); err != nil { + return fmt.Errorf("failed to add default route: %w", err) + } + } + + return ensureInterfaceIsUp(c.iface) +} + +func (c *NetworkInterfaceConfig) setSysctlValues(values map[string]int) error { + for name, value := range values { + name = fmt.Sprintf(name, c.ifaceName) + name = strings.ReplaceAll(name, ".", "/") + + if err := os.WriteFile(path.Join(sysctlBase, name), []byte(strconv.Itoa(value)), sysctlFileMode); err != nil { + return fmt.Errorf("failed to set sysctl %s=%d: %w", name, value, err) + } + } + return nil +} + +func (c *NetworkInterfaceConfig) applyIPv4Dhcp() error { + c.ipv4Lock.Lock() + defer c.ipv4Lock.Unlock() + + return nil +} + +func (c *NetworkInterfaceConfig) applyIPv4Disabled() error { + c.ipv4Lock.Lock() + defer c.ipv4Lock.Unlock() + + addr, err := netlink.AddrList(*c.iface, AF_INET) + if err != nil { + return fmt.Errorf("failed to get address list: %w", err) + } + + for _, a := range addr { + netlink.AddrDel(*c.iface, &a) + } + + return nil +} + +func (c *NetworkInterfaceConfig) applyIPv6Slaac() error { + c.ipv6Lock.Lock() + defer c.ipv6Lock.Unlock() + + if err := c.setSysctlValues(map[string]int{ + "net.ipv6.conf.%s.disable_ipv6": 0, // enable IPv6 + "net.ipv6.conf.%s.accept_ra": 2, // accept even if forwarding is disabled + }); err != nil { + return err + } + + return nil +} + +func (c *NetworkInterfaceConfig) applyIPv6LinkLocalOnly() error { + c.ipv6Lock.Lock() + defer c.ipv6Lock.Unlock() + + if err := c.setSysctlValues(map[string]int{ + "net.ipv6.conf.%s.disable_ipv6": 0, // enable IPv6 + "net.ipv6.conf.%s.accept_ra": 0, // disable RA + }); err != nil { + return err + } + + addr, err := netlink.AddrList(*c.iface, AF_INET6) + if err != nil { + return fmt.Errorf("failed to get address list: %w", err) + } + + for _, a := range addr { + if !a.IP.IsLinkLocalUnicast() { + netlink.AddrDel(*c.iface, &a) + } + } + + return ensureInterfaceIsUp(c.iface) +} + +func (c *NetworkInterfaceConfig) applyIPv6Static(s *NetworkInterfaceState) error { + c.ipv6Lock.Lock() + defer c.ipv6Lock.Unlock() + + config, err := parseAndValidateStaticIPv6Config(c.config.IPv6Static) + if err != nil { + return err + } + + if c.iface == nil { + return fmt.Errorf("interface handle is nil") + } + + if err := c.setSysctlValues(map[string]int{ + "net.ipv6.conf.%s.disable_ipv6": 0, // enable IPv6 + "net.ipv6.conf.%s.accept_ra": 2, // accept even if forwarding is disabled + }); err != nil { + return err + } + + // disable DHCPv6 + s.dhcpClient.SetIPv6(false) + + if err := reconcileLinkAddrs(c.iface, AF_INET6, &config.prefix, c.l); err != nil { + return err + } + + if !checkIfDefaultRouteIsSet(config.gateway, AF_INET6) { + if err := netlink.RouteReplace(&netlink.Route{ + Dst: &ipv6DefaultRoute, + Gw: config.gateway, + LinkIndex: (*c.iface).Attrs().Index, + }); err != nil { + return fmt.Errorf("failed to add default route: %w", err) + } + } + + return ensureInterfaceIsUp(c.iface) +} + +func (c *NetworkInterfaceConfig) disableIPv6() error { + return c.setSysctlValues(map[string]int{ + "net.ipv6.conf.%s.disable_ipv6": 1, // disable IPv6 + }) +} diff --git a/internal/network/static_test.go b/internal/network/static_test.go new file mode 100644 index 0000000..19e02f3 --- /dev/null +++ b/internal/network/static_test.go @@ -0,0 +1,26 @@ +package network + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestApplyIPv4Static(t *testing.T) { + assert.NoError(t, os.Setenv("JETKVM_DEBUG", "1")) + + // conf := &NetworkConfig{ + // IPv4Mode: null.StringFrom("dhcp"), + // // IPv4Static: &IPv4StaticConfig{ + // // Address: null.StringFrom("203.0.113.100"), + // // Netmask: null.StringFrom("255.255.255.0"), + // // Gateway: null.StringFrom("203.0.113.1"), + // // }, + // IPv6Mode: null.StringFrom("disabled"), + // } + // ifc, err := NewNetworkInterfaceConfig("eth0", conf, nil) + // assert.NoError(t, err) + + // assert.NoError(t, ifc.Apply()) +} diff --git a/internal/network/validate.go b/internal/network/validate.go new file mode 100644 index 0000000..c2f0c86 --- /dev/null +++ b/internal/network/validate.go @@ -0,0 +1,187 @@ +package network + +import ( + "bytes" + "fmt" + "net" +) + +var ( + netMask32 = net.IPv4Mask(255, 255, 255, 255) +) + +func parseIP(ip string, family int) (net.IP, error) { + addr := net.ParseIP(ip) + if addr == nil { + return nil, fmt.Errorf("failed to parse IP: %s", ip) + } + + switch family { + case AF_INET: + ia := addr.To4() + if ia == nil { + return nil, fmt.Errorf("address is not a valid IPv4 address") + } + return ia, nil + case AF_INET6: + ia := addr.To16() + if ia == nil { + return nil, fmt.Errorf("address is not a valid IPv6 address") + } + return ia, nil + default: + return nil, fmt.Errorf("invalid family: %d", family) + } +} + +func parseAndValidateUnicastIP(ip string, family int) (net.IP, error) { + addr, err := parseIP(ip, family) + if err != nil { + return nil, err + } + + if !addr.IsGlobalUnicast() { + return nil, fmt.Errorf("address is not a global unicast address") + } + + return addr, nil +} + +func isValidNetMask(s string) bool { + ip := net.ParseIP(s) + if ip == nil { + return false + } + var m net.IPMask + if v4 := ip.To4(); v4 != nil { + m = net.IPMask(v4) // 4 bytes + } else { + v6 := ip.To16() + if v6 == nil { + return false + } + m = net.IPMask(v6) // 16 bytes + } + ones, bits := m.Size() + // Non-contiguous masks return (0,0). /0 is valid and returns (0, 32|128). + return !(ones == 0 && bits == 0) +} + +type parsedIPv4Config struct { + address net.IP + netmask net.IPMask + network net.IPNet + gateway net.IP +} + +type parsedIPv6Config struct { + address net.IP + prefix net.IPNet + gateway net.IP +} + +func parseAndValidateStaticIPv4Config(config *IPv4StaticConfig) (*parsedIPv4Config, error) { + addr, err := parseAndValidateUnicastIP(config.Address.String, AF_INET) + if err != nil { + return nil, fmt.Errorf("failed to parse address: %w", err) + } + + netmask, err := parseIP(config.Netmask.String, AF_INET) + if err != nil { + return nil, fmt.Errorf("failed to parse netmask: %w", err) + } + if !isValidNetMask(config.Netmask.String) { + return nil, fmt.Errorf("netmask is not a valid netmask") + } + + gateway, err := parseAndValidateUnicastIP(config.Gateway.String, AF_INET) + if err != nil { + return nil, fmt.Errorf("failed to parse gateway: %w", err) + } + + if addr.Equal(gateway) { + return nil, fmt.Errorf("address and gateway cannot be the same") + } + + netMask := net.IPMask(netmask) + + ipNet := net.IPNet{ + IP: addr, + Mask: netMask, + } + + if !ipNet.Contains(gateway) && !bytes.Equal(ipNet.Mask, netMask32) { + return nil, fmt.Errorf("address is not in the same subnet as the gateway") + } + + return &parsedIPv4Config{ + address: addr, + netmask: netMask, + network: ipNet, + gateway: gateway, + }, nil +} + +func parseAndValidateStaticIPv6Config(config *IPv6StaticConfig) (*parsedIPv6Config, error) { + addr, prefix, err := net.ParseCIDR(fmt.Sprintf("%s/%d", config.Address.String, config.PrefixLength.Int64)) + if err != nil { + return nil, fmt.Errorf("failed to parse prefix: %w", err) + } + + if !addr.IsGlobalUnicast() { + return nil, fmt.Errorf("address is not a global unicast address") + } + + gateway, err := parseIP(config.Gateway.String, AF_INET6) + if err != nil { + return nil, fmt.Errorf("failed to parse gateway: %w", err) + } + + if addr.Equal(gateway) { + return nil, fmt.Errorf("address and gateway cannot be the same") + } + + if !prefix.Contains(gateway) && !gateway.IsLinkLocalUnicast() { + return nil, fmt.Errorf("gateway is not in the same subnet as the address or is a link-local address") + } + + return &parsedIPv6Config{ + address: addr, + prefix: *prefix, + gateway: gateway, + }, nil +} + +func validateIPv4Config(config *NetworkConfig) error { + switch config.IPv4Mode.String { + case "static": + _, err := parseAndValidateStaticIPv4Config(config.IPv4Static) + return err + case "dhcp": + return nil + case "disabled": + return nil + default: + return fmt.Errorf("invalid IPv4 mode: %s", config.IPv4Mode.String) + } +} + +func validateIPv6Config(config *NetworkConfig) error { + switch config.IPv6Mode.String { + case "static": + _, err := parseAndValidateStaticIPv6Config(config.IPv6Static) + return err + case "slaac": + return nil + case "dhcpv6": + return fmt.Errorf("not implemented") + case "slaac_and_dhcpv6": + return fmt.Errorf("not implemented") + case "link_local": + return nil + case "disabled": + return nil + default: + return fmt.Errorf("invalid IPv6 mode: %s", config.IPv6Mode.String) + } +} diff --git a/internal/network/validate_test.go b/internal/network/validate_test.go new file mode 100644 index 0000000..44fd63d --- /dev/null +++ b/internal/network/validate_test.go @@ -0,0 +1,151 @@ +package network + +import ( + "testing" + + "github.com/guregu/null/v6" + "github.com/stretchr/testify/assert" +) + +func TestValidateInvalidIpv4Netmask(t *testing.T) { + _, err := parseAndValidateStaticIPv4Config( + &IPv4StaticConfig{ + Address: null.StringFrom("192.168.1.100"), + Netmask: null.StringFrom("123.45.67.89"), + Gateway: null.StringFrom("192.168.1.1"), + }, + ) + assert.Error(t, err, "expected error, got nil") +} + +func TestValidateInvalidIpv4Address(t *testing.T) { + _, err := parseAndValidateStaticIPv4Config( + &IPv4StaticConfig{ + Address: null.StringFrom("192.168.1.666"), + Netmask: null.StringFrom("255.255.255.0"), + Gateway: null.StringFrom("192.168.1.1"), + }, + ) + assert.Error(t, err, "expected error, got nil") +} + +func TestValidateIpv4GatewaySameAsAddress(t *testing.T) { + _, err := parseAndValidateStaticIPv4Config( + &IPv4StaticConfig{ + Address: null.StringFrom("192.168.1.1"), + Netmask: null.StringFrom("255.255.255.0"), + Gateway: null.StringFrom("192.168.1.1"), + }, + ) + assert.Error(t, err, "expected error, got nil") +} + +func TestValidateIpv4GatewayNotInNetmask(t *testing.T) { + _, err := parseAndValidateStaticIPv4Config( + &IPv4StaticConfig{ + Address: null.StringFrom("192.168.1.100"), + Netmask: null.StringFrom("255.255.255.0"), + Gateway: null.StringFrom("192.168.2.1"), + }, + ) + assert.Error(t, err, "expected error, got nil") +} + +func TestValidateIpv4GatewayNotGlobalUnicast(t *testing.T) { + _, err := parseAndValidateStaticIPv4Config( + &IPv4StaticConfig{ + Address: null.StringFrom("127.0.0.1"), + Netmask: null.StringFrom("255.255.255.0"), + Gateway: null.StringFrom("127.0.0.1"), + }, + ) + assert.Error(t, err, "expected error, got nil") +} + +func TestValidateIpv4GatewayPointToPoint(t *testing.T) { + _, err := parseAndValidateStaticIPv4Config( + &IPv4StaticConfig{ + Address: null.StringFrom("192.168.1.100"), + Netmask: null.StringFrom("255.255.255.255"), + Gateway: null.StringFrom("203.0.113.1"), + }, + ) + assert.NoError(t, err, "expected no error, got %v", err) +} + +func TestValidateIpv4Config(t *testing.T) { + _, err := parseAndValidateStaticIPv4Config( + &IPv4StaticConfig{ + Address: null.StringFrom("192.168.1.100"), + Netmask: null.StringFrom("255.255.255.0"), + Gateway: null.StringFrom("192.168.1.1"), + }, + ) + assert.NoError(t, err, "expected no error, got %v", err) +} + +func TestValidateIpv6LinkLocalAddress(t *testing.T) { + _, err := parseAndValidateStaticIPv6Config( + &IPv6StaticConfig{ + Address: null.StringFrom("fe80::114"), + PrefixLength: null.IntFrom(64), + Gateway: null.StringFrom("fe80::1"), + }, + ) + assert.Error(t, err, "expected error, got nil") +} + +func TestValidateIpv6GatewaySameAsAddress(t *testing.T) { + _, err := parseAndValidateStaticIPv6Config( + &IPv6StaticConfig{ + Address: null.StringFrom("2001:0db8::2"), + PrefixLength: null.IntFrom(64), + Gateway: null.StringFrom("2001:0db8::2"), + }, + ) + assert.Error(t, err, "expected error, got nil") +} + +func TestValidateIpv6GatewayNotInPrefix(t *testing.T) { + _, err := parseAndValidateStaticIPv6Config( + &IPv6StaticConfig{ + Address: null.StringFrom("2001:0db8::2"), + PrefixLength: null.IntFrom(64), + Gateway: null.StringFrom("2001:0db8:1::1"), + }, + ) + assert.Error(t, err, "expected error, got nil") +} + +func TestValidateIpv6AddressInvalidPrefixLength(t *testing.T) { + _, err := parseAndValidateStaticIPv6Config( + &IPv6StaticConfig{ + Address: null.StringFrom("2001:0db8::2"), + PrefixLength: null.IntFrom(1919), + Gateway: null.StringFrom("2001:0db8::1"), + }, + ) + assert.Error(t, err, "expected error, got nil") +} + +func TestValidateIpv6GatewayLinkLocal(t *testing.T) { + _, err := parseAndValidateStaticIPv6Config( + &IPv6StaticConfig{ + Address: null.StringFrom("2001:0db8::2"), + PrefixLength: null.IntFrom(64), + Gateway: null.StringFrom("fe80::1"), + }, + ) + assert.NoError(t, err, "expected no error, got %v", err) +} + +func TestValidateIpv6Config(t *testing.T) { + _, err := parseAndValidateStaticIPv6Config( + &IPv6StaticConfig{ + Address: null.StringFrom("2001:0db8::2"), + PrefixLength: null.IntFrom(64), + Gateway: null.StringFrom("2001:0db8::1"), + }, + ) + assert.NoError(t, err, "expected no error, got %v", err) +} diff --git a/internal/udhcpc/options.go b/internal/udhcpc/options.go deleted file mode 100644 index 10c9f75..0000000 --- a/internal/udhcpc/options.go +++ /dev/null @@ -1,12 +0,0 @@ -package udhcpc - -func (u *DHCPClient) GetNtpServers() []string { - if u.lease == nil { - return nil - } - servers := make([]string, len(u.lease.NTPServers)) - for i, server := range u.lease.NTPServers { - servers[i] = server.String() - } - return servers -} diff --git a/internal/udhcpc/parser_test.go b/internal/udhcpc/parser_test.go deleted file mode 100644 index 423ab53..0000000 --- a/internal/udhcpc/parser_test.go +++ /dev/null @@ -1,74 +0,0 @@ -package udhcpc - -import ( - "testing" - "time" -) - -func TestUnmarshalDHCPCLease(t *testing.T) { - lease := &Lease{} - err := UnmarshalDHCPCLease(lease, ` -# generated @ Mon Jan 4 19:31:53 UTC 2021 -# 19:31:53 up 0 min, 0 users, load average: 0.72, 0.14, 0.04 -# the date might be inaccurate if the clock is not set -ip=192.168.0.240 -siaddr=192.168.0.1 -sname= -boot_file= -subnet=255.255.255.0 -timezone= -router=192.168.0.1 -timesvr= -namesvr= -dns=172.19.53.2 -logsvr= -cookiesvr= -lprsvr= -hostname= -bootsize= -domain= -swapsvr= -rootpath= -ipttl= -mtu= -broadcast= -ntpsrv=162.159.200.123 -wins= -lease=172800 -dhcptype= -serverid=192.168.0.1 -message= -tftp= -bootfile= - `) - if lease.IPAddress.String() != "192.168.0.240" { - t.Fatalf("expected ip to be 192.168.0.240, got %s", lease.IPAddress.String()) - } - if lease.Netmask.String() != "255.255.255.0" { - t.Fatalf("expected netmask to be 255.255.255.0, got %s", lease.Netmask.String()) - } - if len(lease.Routers) != 1 { - t.Fatalf("expected 1 router, got %d", len(lease.Routers)) - } - if lease.Routers[0].String() != "192.168.0.1" { - t.Fatalf("expected router to be 192.168.0.1, got %s", lease.Routers[0].String()) - } - if len(lease.NTPServers) != 1 { - t.Fatalf("expected 1 timeserver, got %d", len(lease.NTPServers)) - } - if lease.NTPServers[0].String() != "162.159.200.123" { - t.Fatalf("expected timeserver to be 162.159.200.123, got %s", lease.NTPServers[0].String()) - } - if len(lease.DNS) != 1 { - t.Fatalf("expected 1 dns, got %d", len(lease.DNS)) - } - if lease.DNS[0].String() != "172.19.53.2" { - t.Fatalf("expected dns to be 172.19.53.2, got %s", lease.DNS[0].String()) - } - if lease.LeaseTime != 172800*time.Second { - t.Fatalf("expected lease time to be 172800 seconds, got %d", lease.LeaseTime) - } - if err != nil { - t.Fatal(err) - } -} diff --git a/internal/udhcpc/proc.go b/internal/udhcpc/proc.go deleted file mode 100644 index 69c2ab9..0000000 --- a/internal/udhcpc/proc.go +++ /dev/null @@ -1,212 +0,0 @@ -package udhcpc - -import ( - "bytes" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "strconv" - "strings" - "syscall" -) - -func readFileNoStat(filename string) ([]byte, error) { - const maxBufferSize = 1024 * 1024 - - f, err := os.Open(filename) - if err != nil { - return nil, err - } - defer f.Close() - - reader := io.LimitReader(f, maxBufferSize) - return io.ReadAll(reader) -} - -func toCmdline(path string) ([]string, error) { - data, err := readFileNoStat(path) - if err != nil { - return nil, err - } - - if len(data) < 1 { - return []string{}, nil - } - - return strings.Split(string(bytes.TrimRight(data, "\x00")), "\x00"), nil -} - -func (p *DHCPClient) findUdhcpcProcess() (int, error) { - // read procfs for udhcpc processes - // we do not use procfs.AllProcs() because we want to avoid the overhead of reading the entire procfs - processes, err := os.ReadDir("/proc") - if err != nil { - return 0, err - } - - // iterate over the processes - for _, d := range processes { - // check if file is numeric - pid, err := strconv.Atoi(d.Name()) - if err != nil { - continue - } - - // check if it's a directory - if !d.IsDir() { - continue - } - - cmdline, err := toCmdline(filepath.Join("/proc", d.Name(), "cmdline")) - if err != nil { - continue - } - - if len(cmdline) < 1 { - continue - } - - if cmdline[0] != "udhcpc" { - continue - } - - cmdlineText := strings.Join(cmdline, " ") - - // check if it's a udhcpc process - if strings.Contains(cmdlineText, fmt.Sprintf("-i %s", p.InterfaceName)) { - p.logger.Debug(). - Str("pid", d.Name()). - Interface("cmdline", cmdline). - Msg("found udhcpc process") - return pid, nil - } - } - - return 0, errors.New("udhcpc process not found") -} - -func (c *DHCPClient) getProcessPid() (int, error) { - var pid int - if c.pidFile != "" { - // try to read the pid file - pidHandle, err := os.ReadFile(c.pidFile) - if err != nil { - c.logger.Warn().Err(err). - Str("pidFile", c.pidFile).Msg("failed to read udhcpc pid file") - } - - // if it exists, try to read the pid - if pidHandle != nil { - pidFromFile, err := strconv.Atoi(string(pidHandle)) - if err != nil { - c.logger.Warn().Err(err). - Str("pidFile", c.pidFile).Msg("failed to convert pid file to int") - } - pid = pidFromFile - } - } - - // if the pid is 0, try to find the pid using procfs - if pid == 0 { - newPid, err := c.findUdhcpcProcess() - if err != nil { - return 0, err - } - pid = newPid - } - - return pid, nil -} - -func (c *DHCPClient) getProcess() *os.Process { - pid, err := c.getProcessPid() - if err != nil { - return nil - } - - process, err := os.FindProcess(pid) - if err != nil { - c.logger.Warn().Err(err). - Int("pid", pid).Msg("failed to find process") - return nil - } - - return process -} - -func (c *DHCPClient) GetProcess() *os.Process { - if c.process == nil { - process := c.getProcess() - if process == nil { - return nil - } - c.process = process - } - - err := c.process.Signal(syscall.Signal(0)) - if err != nil && errors.Is(err, os.ErrProcessDone) { - oldPid := c.process.Pid - - c.process = nil - c.process = c.getProcess() - if c.process == nil { - c.logger.Error().Msg("failed to find new udhcpc process") - return nil - } - c.logger.Warn(). - Int("oldPid", oldPid). - Int("newPid", c.process.Pid). - Msg("udhcpc process pid changed") - } else if err != nil { - c.logger.Warn().Err(err). - Int("pid", c.process.Pid).Msg("udhcpc process is not running") - } - - return c.process -} - -func (c *DHCPClient) KillProcess() error { - process := c.GetProcess() - if process == nil { - return nil - } - - return process.Kill() -} - -func (c *DHCPClient) ReleaseProcess() error { - process := c.GetProcess() - if process == nil { - return nil - } - - return process.Release() -} - -func (c *DHCPClient) signalProcess(sig syscall.Signal) error { - process := c.GetProcess() - if process == nil { - return nil - } - - s := process.Signal(sig) - if s != nil { - c.logger.Warn().Err(s). - Int("pid", process.Pid). - Str("signal", sig.String()). - Msg("failed to signal udhcpc process") - return s - } - - return nil -} - -func (c *DHCPClient) Renew() error { - return c.signalProcess(syscall.SIGUSR1) -} - -func (c *DHCPClient) Release() error { - return c.signalProcess(syscall.SIGUSR2) -} diff --git a/internal/udhcpc/udhcpc.go b/internal/udhcpc/udhcpc.go deleted file mode 100644 index 128ea66..0000000 --- a/internal/udhcpc/udhcpc.go +++ /dev/null @@ -1,198 +0,0 @@ -package udhcpc - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "reflect" - "time" - - "github.com/fsnotify/fsnotify" - "github.com/rs/zerolog" -) - -const ( - DHCPLeaseFile = "/run/udhcpc.%s.info" - DHCPPidFile = "/run/udhcpc.%s.pid" -) - -type DHCPClient struct { - InterfaceName string - leaseFile string - pidFile string - lease *Lease - logger *zerolog.Logger - process *os.Process - onLeaseChange func(lease *Lease) -} - -type DHCPClientOptions struct { - InterfaceName string - PidFile string - Logger *zerolog.Logger - OnLeaseChange func(lease *Lease) -} - -var defaultLogger = zerolog.New(os.Stdout).Level(zerolog.InfoLevel) - -func NewDHCPClient(options *DHCPClientOptions) *DHCPClient { - if options.Logger == nil { - options.Logger = &defaultLogger - } - - l := options.Logger.With().Str("interface", options.InterfaceName).Logger() - return &DHCPClient{ - InterfaceName: options.InterfaceName, - logger: &l, - leaseFile: fmt.Sprintf(DHCPLeaseFile, options.InterfaceName), - pidFile: options.PidFile, - onLeaseChange: options.OnLeaseChange, - } -} - -func (c *DHCPClient) getWatchPaths() []string { - watchPaths := make(map[string]interface{}) - watchPaths[filepath.Dir(c.leaseFile)] = nil - - if c.pidFile != "" { - watchPaths[filepath.Dir(c.pidFile)] = nil - } - - paths := make([]string, 0) - for path := range watchPaths { - paths = append(paths, path) - } - return paths -} - -// Run starts the DHCP client and watches the lease file for changes. -// this isn't a blocking call, and the lease file is reloaded when a change is detected. -func (c *DHCPClient) Run() error { - err := c.loadLeaseFile() - if err != nil && !errors.Is(err, os.ErrNotExist) { - return err - } - - watcher, err := fsnotify.NewWatcher() - if err != nil { - return err - } - defer watcher.Close() - - go func() { - for { - select { - case event, ok := <-watcher.Events: - if !ok { - continue - } - if !event.Has(fsnotify.Write) && !event.Has(fsnotify.Create) { - continue - } - - if event.Name == c.leaseFile { - c.logger.Debug(). - Str("event", event.Op.String()). - Str("path", event.Name). - Msg("udhcpc lease file updated, reloading lease") - _ = c.loadLeaseFile() - } - case err, ok := <-watcher.Errors: - if !ok { - return - } - c.logger.Error().Err(err).Msg("error watching lease file") - } - } - }() - - for _, path := range c.getWatchPaths() { - err = watcher.Add(path) - if err != nil { - c.logger.Error(). - Err(err). - Str("path", path). - Msg("failed to watch directory") - return err - } - } - - // TODO: update udhcpc pid file - // we'll comment this out for now because the pid might change - // process := c.GetProcess() - // if process == nil { - // c.logger.Error().Msg("udhcpc process not found") - // } - - // block the goroutine until the lease file is updated - <-make(chan struct{}) - - return nil -} - -func (c *DHCPClient) loadLeaseFile() error { - file, err := os.ReadFile(c.leaseFile) - if err != nil { - return err - } - - data := string(file) - if data == "" { - c.logger.Debug().Msg("udhcpc lease file is empty") - return nil - } - - lease := &Lease{} - err = UnmarshalDHCPCLease(lease, string(file)) - if err != nil { - return err - } - - isFirstLoad := c.lease == nil - - // Skip processing if lease hasn't changed to avoid unnecessary wake-ups. - if reflect.DeepEqual(c.lease, lease) { - return nil - } - - c.lease = lease - - if lease.IPAddress == nil { - c.logger.Info(). - Interface("lease", lease). - Str("data", string(file)). - Msg("udhcpc lease cleared") - return nil - } - - msg := "udhcpc lease updated" - if isFirstLoad { - msg = "udhcpc lease loaded" - } - - leaseExpiry, err := lease.SetLeaseExpiry() - if err != nil { - c.logger.Error().Err(err).Msg("failed to get dhcp lease expiry") - } else { - expiresIn := time.Until(leaseExpiry) - c.logger.Info(). - Interface("expiry", leaseExpiry). - Str("expiresIn", expiresIn.String()). - Msg("current dhcp lease expiry time calculated") - } - - c.onLeaseChange(lease) - - c.logger.Info(). - Str("ip", lease.IPAddress.String()). - Str("leaseTime", lease.LeaseTime.String()). - Interface("data", lease). - Msg(msg) - - return nil -} - -func (c *DHCPClient) GetLease() *Lease { - return c.lease -} diff --git a/network.go b/network.go index d4f46e7..0fae41a 100644 --- a/network.go +++ b/network.go @@ -3,8 +3,8 @@ package kvm import ( "fmt" + "github.com/jetkvm/kvm/internal/dhclient" "github.com/jetkvm/kvm/internal/network" - "github.com/jetkvm/kvm/internal/udhcpc" ) const ( @@ -53,7 +53,7 @@ func initNetwork() error { OnInitialCheck: func(state *network.NetworkInterfaceState) { networkStateChanged() }, - OnDhcpLeaseChange: func(lease *udhcpc.Lease) { + OnDhcpLeaseChange: func(lease *dhclient.Lease) { networkStateChanged() if currentSession == nil { diff --git a/ui/package-lock.json b/ui/package-lock.json index 72a4849..43d5105 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -28,6 +28,7 @@ "react": "^19.1.1", "react-animate-height": "^3.2.3", "react-dom": "^19.1.1", + "react-hook-form": "^7.62.0", "react-hot-toast": "^2.5.2", "react-icons": "^5.5.0", "react-router-dom": "^6.22.3", @@ -5786,6 +5787,21 @@ "react": "^19.1.1" } }, + "node_modules/react-hook-form": { + "version": "7.62.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.62.0.tgz", + "integrity": "sha512-7KWFejc98xqG/F4bAxpL41NB3o1nnvQO1RWZT3TqRZYL8RryQETGfEdVnJN2fy1crCiBLLjkRBVK05j24FxJGA==", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, "node_modules/react-hot-toast": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/react-hot-toast/-/react-hot-toast-2.5.2.tgz", diff --git a/ui/src/routes/devices.$id.settings.network.tsx b/ui/src/routes/devices.$id.settings.network.tsx index ca5159c..623ac99 100644 --- a/ui/src/routes/devices.$id.settings.network.tsx +++ b/ui/src/routes/devices.$id.settings.network.tsx @@ -267,7 +267,7 @@ export default function SettingsNetworkRoute() { placeholder="http://proxy.example.com:8080" {...register("http_proxy", { validate: (value: string | null) => { - if (value === "") return true; + if (value === "" || value === null) return true; if (!validator.isURL(value || "", { protocols: ["http", "https"] })) { return "Invalid HTTP proxy URL"; }