├── .github └── workflows │ └── ci.yml ├── .gitignore ├── AUTHORS.txt ├── LICENSE ├── Makefile ├── cmd.go ├── cmd_test.go ├── cmd_unix.go ├── cmd_windows.go ├── lib ├── BUILD.bazel ├── hostsfile.go ├── hostsfile_nonwindows.go ├── hostsfile_plan9.go ├── hostsfile_test.go ├── hostsfile_unix.go └── hostsfile_windows.go └── readme.md /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | name: Test 3 | jobs: 4 | test: 5 | strategy: 6 | matrix: 7 | go-version: [1.18.x] 8 | platform: [ubuntu-latest] 9 | runs-on: ${{ matrix.platform }} 10 | steps: 11 | - name: Install Go 12 | uses: actions/setup-go@v2 13 | with: 14 | go-version: ${{ matrix.go-version }} 15 | - uses: actions/checkout@v2 16 | with: 17 | path: './src/github.com/kevinburke/hostsfile' 18 | # staticcheck needs this for GOPATH 19 | - run: echo "GOPATH=$GITHUB_WORKSPACE" >> $GITHUB_ENV 20 | - run: echo "GO111MODULE=off" >> $GITHUB_ENV 21 | - run: echo "PATH=$GITHUB_WORKSPACE/bin:$PATH" >> $GITHUB_ENV 22 | - name: Run tests 23 | run: make race-test 24 | working-directory: './src/github.com/kevinburke/hostsfile' 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /releases 2 | /bazel-* 3 | -------------------------------------------------------------------------------- /AUTHORS.txt: -------------------------------------------------------------------------------- 1 | Kevin Burke 2 | lende 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | go-hostsfile is released under the MIT License (MIT). 2 | 3 | // begin normal license text 4 | 5 | Copyright (c) 2014 Kevin Burke 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy of 8 | this software and associated documentation files (the "Software"), to deal in 9 | the Software without restriction, including without limitation the rights to 10 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 11 | the Software, and to permit persons to whom the Software is furnished to do so, 12 | subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 19 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 20 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 21 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 22 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 23 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test release 2 | 3 | SHELL = /bin/bash -o pipefail 4 | 5 | BUMP_VERSION := $(GOPATH)/bin/bump_version 6 | STATICCHECK := $(GOPATH)/bin/staticcheck 7 | RELEASE := $(GOPATH)/bin/github-release 8 | WRITE_MAILMAP := $(GOPATH)/bin/write_mailmap 9 | 10 | UNAME := $(shell uname) 11 | 12 | test: 13 | go test ./... 14 | 15 | $(STATICCHECK): 16 | go get honnef.co/go/tools/cmd/staticcheck 17 | 18 | $(BUMP_VERSION): 19 | go get -u github.com/kevinburke/bump_version 20 | 21 | $(RELEASE): 22 | go get -u github.com/aktau/github-release 23 | 24 | $(WRITE_MAILMAP): 25 | go get -u github.com/kevinburke/write_mailmap 26 | 27 | force: ; 28 | 29 | AUTHORS.txt: force | $(WRITE_MAILMAP) 30 | $(WRITE_MAILMAP) > AUTHORS.txt 31 | 32 | authors: AUTHORS.txt 33 | 34 | lint: | $(STATICCHECK) 35 | $(STATICCHECK) ./... 36 | go vet ./... 37 | 38 | race-test: lint 39 | go test -race ./... 40 | 41 | # Run "GITHUB_TOKEN=my-token make release version=0.x.y" to release a new version. 42 | release: race-test 43 | $(BUMP_VERSION) minor cmd.go 44 | git push origin --tags 45 | -------------------------------------------------------------------------------- /cmd.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "errors" 5 | "flag" 6 | "fmt" 7 | "io" 8 | "net" 9 | "os" 10 | 11 | hostsfile "github.com/kevinburke/hostsfile/lib" 12 | ) 13 | 14 | const Version = "1.6" 15 | 16 | func checkError(err error) { 17 | if err != nil { 18 | fmt.Fprintln(os.Stderr, err.Error()) 19 | os.Exit(2) 20 | } 21 | } 22 | 23 | const usage = `Hostsfile manages your /etc/hosts file. 24 | 25 | The commands are: 26 | 27 | add [] 28 | remove [] 29 | version Print the version 30 | help You're looking at it. 31 | 32 | ` 33 | 34 | const addUsage = `Add a set of hostnames to your /etc/hosts file. 35 | 36 | The last argument is the IP address to use for all host files. 37 | 38 | Example: 39 | 40 | hostsfile add www.facebook.com www.twitter.com 127.0.0.1 41 | hostsfile add --dry-run www.facebook.com 127.0.0.1 42 | ` 43 | 44 | const getUsage = `Get an IP address from your /etc/hosts file. 45 | 46 | If a hostname is not found in your /etc/hosts file, an error message will be 47 | written to stderr, and the script will exit with a non-zero return code. 48 | 49 | Example: 50 | 51 | hostsfile get www.facebook.com 52 | > 127.0.0.1 53 | ` 54 | 55 | const removeUsage = `Remove a set of hostnames from your /etc/hosts file. 56 | 57 | Example: 58 | 59 | hostsfile remove www.facebook.com www.twitter.com 60 | hostsfile remove --dry-run www.facebook.com 61 | ` 62 | 63 | func usg(msg string, fs *flag.FlagSet) func() { 64 | return func() { 65 | fmt.Fprint(os.Stderr, msg) 66 | fs.PrintDefaults() 67 | os.Exit(2) 68 | } 69 | } 70 | 71 | // doAdd reads a file from hfile, adds the given args ["www.facebook.com", 72 | // "127.0.0.1"] and writes the new file to out. 73 | func doAdd(hfile io.Reader, out io.Writer, args []string) error { 74 | if len(args) == 0 { 75 | return errors.New("please provide a domain to add") 76 | } 77 | if len(args) == 1 { 78 | return fmt.Errorf(`provide a domain and an IP address ("hostsfile add www.facebook.com 127.0.0.1")`) 79 | } 80 | lastAddr := args[len(args)-1] 81 | ip, err := net.ResolveIPAddr("ip", lastAddr) 82 | if err != nil { 83 | return err 84 | } 85 | h, err := hostsfile.Decode(hfile) 86 | if err != nil { 87 | return err 88 | } 89 | for _, arg := range args[:len(args)-1] { 90 | err = h.Set(*ip, arg) 91 | if err != nil { 92 | return err 93 | } 94 | } 95 | return hostsfile.Encode(out, h) 96 | } 97 | 98 | func doRemove(hfile io.Reader, out io.Writer, args []string) error { 99 | h, err := hostsfile.Decode(hfile) 100 | if err != nil { 101 | return err 102 | } 103 | for _, arg := range args { 104 | // XXX remove arguments 105 | h.Remove(arg) 106 | } 107 | return hostsfile.Encode(out, h) 108 | } 109 | 110 | func doRename(writtenFile *os.File, renameTo string) error { 111 | return os.Rename(writtenFile.Name(), renameTo) 112 | } 113 | 114 | func checkWritable(file string) error { 115 | f, err := os.OpenFile(file, os.O_RDONLY, 0644) 116 | if err != nil { 117 | return err 118 | } 119 | f.Close() 120 | return nil 121 | } 122 | 123 | // dataPipedIn returns true if the user piped data via stdin. 124 | func dataPipedIn() bool { 125 | stat, err := os.Stdin.Stat() 126 | if err != nil { 127 | return false 128 | } 129 | return (stat.Mode() & os.ModeCharDevice) == 0 130 | } 131 | 132 | func main() { 133 | flag.Usage = usg(usage, flag.CommandLine) 134 | dryRunArg := flag.Bool("dry-run", false, "Print the updated host file to stdout instead of writing it") 135 | fileArg := flag.String("file", hostsfile.Location, "File to read/write") 136 | 137 | addflags := flag.NewFlagSet("add", flag.ExitOnError) 138 | addflags.Usage = usg(addUsage, addflags) 139 | addflags.BoolVar(dryRunArg, "dry-run", false, "Print the updated host file to stdout instead of writing it") 140 | addflags.StringVar(fileArg, "file", hostsfile.Location, "File to read/write") 141 | 142 | getflags := flag.NewFlagSet("get", flag.ExitOnError) 143 | getflags.Usage = usg(getUsage, getflags) 144 | getflags.StringVar(fileArg, "file", hostsfile.Location, "File to read") 145 | 146 | removeflags := flag.NewFlagSet("remove", flag.ExitOnError) 147 | removeflags.Usage = usg(removeUsage, removeflags) 148 | removeflags.BoolVar(dryRunArg, "dry-run", false, "Print the updated host file to stdout instead of writing it") 149 | removeflags.StringVar(fileArg, "file", hostsfile.Location, "File to read/write") 150 | 151 | flag.Parse() 152 | subargs := []string{} 153 | switch flag.NArg() { 154 | case 0: 155 | flag.Usage() 156 | return 157 | case 1: 158 | break 159 | default: 160 | subargs = flag.Args()[1:] 161 | } 162 | switch flag.Arg(0) { 163 | case "add": 164 | err := addflags.Parse(subargs) 165 | checkError(err) 166 | if !*dryRunArg { 167 | err = checkWritable(*fileArg) 168 | checkError(err) 169 | } 170 | var r io.ReadCloser 171 | if dataPipedIn() { 172 | r = io.NopCloser(os.Stdin) 173 | } else { 174 | f, err := os.Open(*fileArg) 175 | checkError(err) 176 | r = f 177 | } 178 | if *dryRunArg { 179 | err = doAdd(r, os.Stdout, addflags.Args()) 180 | checkError(err) 181 | } else { 182 | tmp, err := tempFile(*fileArg) 183 | checkError(err) 184 | err = doAdd(r, tmp, addflags.Args()) 185 | checkError(err) 186 | r.Close() 187 | err = tmp.Close() 188 | checkError(err) 189 | err = doRename(tmp, *fileArg) 190 | checkError(err) 191 | } 192 | case "get": 193 | err := getflags.Parse(subargs) 194 | checkError(err) 195 | args := getflags.Args() 196 | if len(args) != 1 { 197 | fmt.Fprintf(os.Stderr, "hostsfile: please provide a hostname to get\n\n") 198 | getflags.Usage() 199 | os.Exit(2) 200 | } 201 | var r io.ReadCloser 202 | if dataPipedIn() { 203 | r = io.NopCloser(os.Stdin) 204 | } else { 205 | f, err := os.Open(*fileArg) 206 | checkError(err) 207 | r = f 208 | } 209 | h, err := hostsfile.Decode(r) 210 | checkError(err) 211 | addrs := make(map[string]bool) 212 | for _, record := range h.Records() { 213 | for name := range record.Hostnames { 214 | if name == args[0] { 215 | addrs[record.IpAddress.String()] = true 216 | } 217 | } 218 | } 219 | if len(addrs) == 0 { 220 | fmt.Fprintf(os.Stderr, "hostsfile: host %q not found\n", args[0]) 221 | os.Exit(1) 222 | } 223 | for name := range addrs { 224 | fmt.Println(name) 225 | } 226 | os.Exit(0) 227 | case "remove": 228 | err := removeflags.Parse(subargs) 229 | checkError(err) 230 | if !*dryRunArg { 231 | err = checkWritable(*fileArg) 232 | checkError(err) 233 | } 234 | var r io.ReadCloser 235 | if dataPipedIn() { 236 | r = io.NopCloser(os.Stdin) 237 | } else { 238 | f, err := os.Open(*fileArg) 239 | checkError(err) 240 | r = f 241 | } 242 | if *dryRunArg { 243 | err = doRemove(r, os.Stdout, removeflags.Args()) 244 | checkError(err) 245 | } else { 246 | tmp, err := tempFile(*fileArg) 247 | checkError(err) 248 | err = doRemove(r, tmp, removeflags.Args()) 249 | checkError(err) 250 | r.Close() 251 | err = tmp.Close() 252 | checkError(err) 253 | err = doRename(tmp, *fileArg) 254 | checkError(err) 255 | } 256 | case "help": 257 | switch flag.Arg(1) { 258 | case "add": 259 | usg(addUsage, addflags)() 260 | case "remove": 261 | usg(removeUsage, removeflags)() 262 | default: 263 | usg(usage, flag.CommandLine)() 264 | } 265 | case "version": 266 | fmt.Fprintf(os.Stdout, "hostsfile version %s\n", Version) 267 | os.Exit(0) 268 | default: 269 | fmt.Fprintf(os.Stderr, "hostsfile: unknown subcommand %q\n\n", flag.Arg(0)) 270 | usg(usage, flag.CommandLine)() 271 | } 272 | } 273 | -------------------------------------------------------------------------------- /cmd_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import "testing" 4 | 5 | func TestVersionNonEmpty(t *testing.T) { 6 | if Version == "" { 7 | t.Fatal("empty Version string") 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /cmd_unix.go: -------------------------------------------------------------------------------- 1 | //go:build darwin || dragonfly || freebsd || linux || nacl || netbsd || openbsd || solaris 2 | // +build darwin dragonfly freebsd linux nacl netbsd openbsd solaris 3 | 4 | package main 5 | 6 | import ( 7 | "os" 8 | "syscall" 9 | ) 10 | 11 | // tempFile creates a new temporary hosts-file in an appropriate directory, 12 | // opens the file for writing, and returns the resulting *os.File. 13 | func tempFile(hostsPath string) (*os.File, error) { 14 | fs, err := os.Stat(hostsPath) 15 | if err != nil { 16 | return nil, err 17 | } 18 | f, err := os.CreateTemp("", "hostsfile-temp") 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | // Set file mode to the same as the hosts-file. 24 | if err = os.Chmod(f.Name(), fs.Mode()); err != nil { 25 | return nil, err 26 | } 27 | 28 | // Set ownership to the same as the hosts-file. 29 | uid := fs.Sys().(*syscall.Stat_t).Uid 30 | gid := fs.Sys().(*syscall.Stat_t).Gid 31 | if err = os.Chown(f.Name(), int(uid), int(gid)); err != nil { 32 | return nil, err 33 | } 34 | 35 | return f, nil 36 | } 37 | -------------------------------------------------------------------------------- /cmd_windows.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | ) 7 | 8 | // tempFile creates a new temporary hosts-file in an appropriate directory, 9 | // opens the file for writing, and returns the resulting *os.File. 10 | func tempFile(hostsPath string) (*os.File, error) { 11 | // Create the temporary file in the same location as the hosts-file to inherit 12 | // the correct permissions from the parent directory. 13 | return os.CreateTemp(filepath.Dir(hostsPath), "hostsfile-temp") 14 | } 15 | -------------------------------------------------------------------------------- /lib/BUILD.bazel: -------------------------------------------------------------------------------- 1 | load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test") 2 | 3 | go_library( 4 | name = "go_default_library", 5 | srcs = [ 6 | "hostsfile.go", 7 | "hostsfile_nonwindows.go", 8 | "hostsfile_plan9.go", 9 | "hostsfile_unix.go", 10 | "hostsfile_windows.go", 11 | ], 12 | importpath = "github.com/kevinburke/hostsfile/lib", 13 | visibility = ["//visibility:public"], 14 | ) 15 | 16 | go_test( 17 | name = "go_default_test", 18 | timeout = "short", 19 | srcs = ["hostsfile_test.go"], 20 | embed = [":go_default_library"], 21 | importpath = "github.com/kevinburke/hostsfile/lib", 22 | ) 23 | -------------------------------------------------------------------------------- /lib/hostsfile.go: -------------------------------------------------------------------------------- 1 | package hostsfile 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "net" 8 | "sort" 9 | "strings" 10 | "sync" 11 | ) 12 | 13 | // Represents a hosts file. Records match a single line in the file. 14 | type Hostsfile struct { 15 | records []*Record 16 | } 17 | 18 | // Records returns an array of all entries in the hostsfile. 19 | func (h *Hostsfile) Records() []*Record { 20 | return h.records 21 | } 22 | 23 | // A single line in the hosts file 24 | type Record struct { 25 | IpAddress net.IPAddr 26 | Hostnames map[string]bool 27 | comment string 28 | isBlank bool 29 | mu sync.Mutex 30 | } 31 | 32 | // returns true if a and b are not both ipv4 addresses 33 | func matchProtocols(a, b net.IP) bool { 34 | ato4 := a.To4() 35 | bto4 := b.To4() 36 | return (ato4 == nil && bto4 == nil) || 37 | (ato4 != nil && bto4 != nil) 38 | } 39 | 40 | // Adds a record to the list. If the hostname is present with a different IP 41 | // address, it will be reassigned. If the record is already present with the 42 | // same hostname/IP address data, it will not be added again. 43 | func (h *Hostsfile) Set(ipa net.IPAddr, hostname string) error { 44 | addKey := true 45 | for i := 0; i < len(h.records); i++ { 46 | record := h.records[i] 47 | record.mu.Lock() 48 | _, ok := record.Hostnames[hostname] 49 | if ok { 50 | if record.IpAddress.IP.Equal(ipa.IP) { 51 | // tried to set a key that exists with the same IP address, 52 | // nothing to do 53 | addKey = false 54 | } else { 55 | // if the protocol matches, delete the key and be sure to add 56 | // a new record. 57 | if matchProtocols(record.IpAddress.IP, ipa.IP) { 58 | delete(record.Hostnames, hostname) 59 | if len(record.Hostnames) == 0 { 60 | // delete the record 61 | h.records = append(h.records[:i], h.records[i+1:]...) 62 | } 63 | addKey = true 64 | } 65 | } 66 | } 67 | record.mu.Unlock() 68 | } 69 | 70 | if addKey { 71 | nr := &Record{ 72 | IpAddress: ipa, 73 | Hostnames: map[string]bool{hostname: true}, 74 | } 75 | h.records = append(h.records, nr) 76 | } 77 | return nil 78 | } 79 | 80 | // Removes all references to hostname from the file. Returns false if the 81 | // record was not found in the file. 82 | func (h *Hostsfile) Remove(hostname string) (found bool) { 83 | for i := len(h.records) - 1; i >= 0; i-- { 84 | record := h.records[i] 85 | record.mu.Lock() 86 | if _, ok := record.Hostnames[hostname]; ok { 87 | delete(record.Hostnames, hostname) 88 | if len(record.Hostnames) == 0 { 89 | // delete the record 90 | if i == len(h.records)-1 { 91 | h.records = h.records[:len(h.records)-1] 92 | } else { 93 | h.records = append(h.records[:i], h.records[i+1:]...) 94 | } 95 | } 96 | found = true 97 | } 98 | record.mu.Unlock() 99 | } 100 | return 101 | } 102 | 103 | // Decodes the raw text of a hostsfile into a Hostsfile struct. If a line 104 | // contains both an IP address and a comment, the comment will be lost. 105 | // 106 | // Interface example from the image package. 107 | func Decode(rdr io.Reader) (Hostsfile, error) { 108 | var h Hostsfile 109 | scanner := bufio.NewScanner(rdr) 110 | for scanner.Scan() { 111 | rawLine := scanner.Text() 112 | line := strings.TrimSpace(rawLine) 113 | r := new(Record) 114 | if len(line) == 0 { 115 | r.isBlank = true 116 | } else if line[0] == '#' { 117 | // comment line or blank line: skip it. 118 | r.comment = line 119 | } else { 120 | vals := strings.Fields(line) 121 | if len(vals) <= 1 { 122 | return Hostsfile{}, fmt.Errorf("invalid hostsfile entry: %s", line) 123 | } 124 | ip, err := net.ResolveIPAddr("ip", vals[0]) 125 | if err != nil { 126 | return Hostsfile{}, err 127 | } 128 | r = &Record{ 129 | IpAddress: *ip, 130 | Hostnames: map[string]bool{}, 131 | } 132 | for i := 1; i < len(vals); i++ { 133 | name := vals[i] 134 | if len(name) > 0 && name[0] == '#' { 135 | // beginning of a comment. rest of the line is bunk 136 | break 137 | } 138 | r.Hostnames[name] = true 139 | } 140 | } 141 | h.records = append(h.records, r) 142 | } 143 | if err := scanner.Err(); err != nil { 144 | return Hostsfile{}, err 145 | } 146 | return h, nil 147 | } 148 | 149 | // Return the text representation of the hosts file. 150 | func Encode(w io.Writer, h Hostsfile) error { 151 | for _, record := range h.records { 152 | var toWrite string 153 | if record.isBlank { 154 | toWrite = "" 155 | } else if len(record.comment) > 0 { 156 | toWrite = record.comment 157 | } else { 158 | out := make([]string, len(record.Hostnames)) 159 | i := 0 160 | for name := range record.Hostnames { 161 | out[i] = name 162 | i++ 163 | } 164 | sort.Strings(out) 165 | out = append([]string{record.IpAddress.String()}, out...) 166 | toWrite = strings.Join(out, " ") 167 | } 168 | toWrite += eol 169 | _, err := w.Write([]byte(toWrite)) 170 | if err != nil { 171 | return err 172 | } 173 | } 174 | return nil 175 | } 176 | -------------------------------------------------------------------------------- /lib/hostsfile_nonwindows.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package hostsfile 5 | 6 | // OS-specific newline character(s). 7 | const eol = "\n" 8 | -------------------------------------------------------------------------------- /lib/hostsfile_plan9.go: -------------------------------------------------------------------------------- 1 | package hostsfile 2 | 3 | // OS-specific default hosts-file location. 4 | var Location = "/lib/ndb/hosts" 5 | -------------------------------------------------------------------------------- /lib/hostsfile_test.go: -------------------------------------------------------------------------------- 1 | package hostsfile 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net" 7 | "path/filepath" 8 | "reflect" 9 | "runtime" 10 | "strings" 11 | "testing" 12 | ) 13 | 14 | // assert fails the test if the condition is false. 15 | func assert(tb testing.TB, condition bool, msg string, v ...interface{}) { 16 | if !condition { 17 | _, file, line, _ := runtime.Caller(1) 18 | fmt.Printf("\033[31m%s:%d: "+msg+"\033[39m\n\n", append([]interface{}{filepath.Base(file), line}, v...)...) 19 | tb.FailNow() 20 | } 21 | } 22 | 23 | // ok fails the test if an err is not nil. 24 | func ok(tb testing.TB, err error) { 25 | if err != nil { 26 | _, file, line, _ := runtime.Caller(1) 27 | fmt.Printf("\033[31m%s:%d: unexpected error: %s\033[39m\n\n", filepath.Base(file), line, err.Error()) 28 | tb.FailNow() 29 | } 30 | } 31 | 32 | // equals fails the test if exp is not equal to act. 33 | func equals(tb testing.TB, exp, act interface{}) { 34 | if !reflect.DeepEqual(exp, act) { 35 | _, file, line, _ := runtime.Caller(1) 36 | fmt.Printf("\033[31m%s:%d:\n\texp: %#v\n\tgot: %#v\033[39m\n", filepath.Base(file), line, exp, act) 37 | tb.FailNow() 38 | } 39 | } 40 | 41 | func TestDecode(t *testing.T) { 42 | t.Parallel() 43 | sampledata := "127.0.0.1 foobar\n# this is a comment\n\n10.0.0.1 anotheralias" 44 | h, err := Decode(strings.NewReader(sampledata)) 45 | if err != nil { 46 | t.Error(err.Error()) 47 | } 48 | firstRecord := h.records[0] 49 | 50 | equals(t, firstRecord.IpAddress.IP.String(), "127.0.0.1") 51 | equals(t, firstRecord.Hostnames["foobar"], true) 52 | equals(t, len(firstRecord.Hostnames), 1) 53 | 54 | equals(t, h.records[1].comment, "# this is a comment") 55 | equals(t, h.records[2].isBlank, true) 56 | 57 | aliasSample := "127.0.0.1 name alias1 alias2 alias3" 58 | h, err = Decode(strings.NewReader(aliasSample)) 59 | ok(t, err) 60 | hns := h.records[0].Hostnames 61 | equals(t, len(hns), 4) 62 | equals(t, hns["alias3"], true) 63 | 64 | badline := strings.NewReader("blah") 65 | h, err = Decode(badline) 66 | if err == nil { 67 | t.Error("expected Decode(\"blah\") to return invalid, got no error") 68 | } 69 | if err.Error() != "invalid hostsfile entry: blah" { 70 | t.Errorf("expected Decode(\"blah\") to return invalid, got %s", err.Error()) 71 | } 72 | 73 | h, err = Decode(strings.NewReader("##\n127.0.0.1\tlocalhost 2nd-alias")) 74 | ok(t, err) 75 | equals(t, h.records[1].Hostnames["2nd-alias"], true) 76 | 77 | h, err = Decode(strings.NewReader("##\n127.0.0.1\tlocalhost # a comment")) 78 | ok(t, err) 79 | equals(t, h.records[0].Hostnames["#"], false) 80 | equals(t, h.records[0].Hostnames["a"], false) 81 | } 82 | 83 | func sample(t *testing.T) Hostsfile { 84 | one27, err := net.ResolveIPAddr("ip", "127.0.0.1") 85 | ok(t, err) 86 | one92, err := net.ResolveIPAddr("ip", "192.168.0.1") 87 | ok(t, err) 88 | oneip6, err := net.ResolveIPAddr("ip", "fe80::1%lo0") 89 | ok(t, err) 90 | return Hostsfile{ 91 | records: []*Record{ 92 | { 93 | IpAddress: *one27, 94 | Hostnames: map[string]bool{"foobar": true}, 95 | }, 96 | { 97 | IpAddress: *one92, 98 | Hostnames: map[string]bool{"bazbaz": true, "blahbar": true}, 99 | }, 100 | { 101 | IpAddress: *oneip6, 102 | Hostnames: map[string]bool{"bazbaz": true}, 103 | }, 104 | }, 105 | } 106 | } 107 | 108 | func comment(t *testing.T) Hostsfile { 109 | one92, err := net.ResolveIPAddr("ip", "192.168.0.1") 110 | ok(t, err) 111 | return Hostsfile{ 112 | records: []*Record{ 113 | { 114 | comment: "# Don't delete this line!", 115 | }, 116 | { 117 | comment: "shouldnt matter", 118 | isBlank: true, 119 | }, 120 | { 121 | IpAddress: *one92, 122 | Hostnames: map[string]bool{"bazbaz": true}, 123 | }, 124 | }, 125 | } 126 | } 127 | 128 | func TestEncode(t *testing.T) { 129 | t.Parallel() 130 | b := new(bytes.Buffer) 131 | err := Encode(b, sample(t)) 132 | ok(t, err) 133 | equals(t, b.String(), "127.0.0.1 foobar\n192.168.0.1 bazbaz blahbar\nfe80::1%lo0 bazbaz\n") 134 | 135 | b.Reset() 136 | err = Encode(b, comment(t)) 137 | ok(t, err) 138 | equals(t, b.String(), "# Don't delete this line!\n\n192.168.0.1 bazbaz\n") 139 | } 140 | 141 | func TestRemove(t *testing.T) { 142 | t.Parallel() 143 | hCopy := sample(t) 144 | equals(t, len(hCopy.records[1].Hostnames), 2) 145 | hCopy.Remove("bazbaz") 146 | equals(t, len(hCopy.records[1].Hostnames), 1) 147 | ok := hCopy.records[1].Hostnames["blahbar"] 148 | assert(t, ok, "item \"blahbar\" not found in %v", hCopy.records[1].Hostnames) 149 | hCopy.Remove("blahbar") 150 | equals(t, len(hCopy.records), 1) 151 | } 152 | 153 | func TestProtocols(t *testing.T) { 154 | t.Parallel() 155 | one92, _ := net.ResolveIPAddr("ip", "192.168.3.7") 156 | ip6, _ := net.ResolveIPAddr("ip", "::1") 157 | equals(t, matchProtocols(one92.IP, ip6.IP), false) 158 | equals(t, matchProtocols(one92.IP, one92.IP), true) 159 | equals(t, matchProtocols(ip6.IP, ip6.IP), true) 160 | } 161 | 162 | func TestSet(t *testing.T) { 163 | t.Parallel() 164 | hCopy := sample(t) 165 | one0, err := net.ResolveIPAddr("ip", "10.0.0.1") 166 | ok(t, err) 167 | hCopy.Set(*one0, "tendot") 168 | equals(t, len(hCopy.records), 4) 169 | equals(t, hCopy.records[3].Hostnames["tendot"], true) 170 | equals(t, hCopy.records[3].IpAddress.String(), "10.0.0.1") 171 | 172 | // appending same element shouldn't change anything 173 | hCopy.Set(*one0, "tendot") 174 | equals(t, len(hCopy.records), 4) 175 | 176 | one92, err := net.ResolveIPAddr("ip", "192.168.3.7") 177 | ok(t, err) 178 | hCopy.Set(*one92, "tendot") 179 | equals(t, hCopy.records[3].IpAddress.String(), "192.168.3.7") 180 | 181 | ip6, err := net.ResolveIPAddr("ip", "::1") 182 | ok(t, err) 183 | hCopy.Set(*ip6, "tendot") 184 | equals(t, len(hCopy.records), 5) 185 | equals(t, hCopy.records[4].IpAddress.String(), "::1") 186 | } 187 | -------------------------------------------------------------------------------- /lib/hostsfile_unix.go: -------------------------------------------------------------------------------- 1 | //go:build darwin || dragonfly || freebsd || linux || nacl || netbsd || openbsd || solaris 2 | // +build darwin dragonfly freebsd linux nacl netbsd openbsd solaris 3 | 4 | package hostsfile 5 | 6 | // OS-specific default hosts-file location. 7 | var Location = "/etc/hosts" 8 | -------------------------------------------------------------------------------- /lib/hostsfile_windows.go: -------------------------------------------------------------------------------- 1 | package hostsfile 2 | 3 | import "os" 4 | 5 | // OS-specific default hosts-file location. 6 | var Location = os.Getenv("SystemRoot") + "\\System32\\drivers\\etc\\hosts" 7 | 8 | // OS-specific newline character(s). 9 | const eol = "\r\n" 10 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # hostsfile 2 | 3 | This library, and the associated command line binary, will help you manipulate 4 | your /etc/hosts file. Both the library and the binary will leave comments 5 | and other metadata in the /etc/hosts file as is, appending or removing only 6 | the lines that you want changed. A description of the API [can be found at 7 | godoc][godoc]. 8 | 9 | ## Installation 10 | 11 | On Mac, install via Homebrew: 12 | 13 | ``` 14 | brew install kevinburke/safe/hostsfile 15 | ``` 16 | 17 | 18 | If you have a Go development environment, you can install via source code: 19 | 20 | go get github.com/kevinburke/hostsfile@latest 21 | 22 | ## Command Line Usage 23 | 24 | Easily add and remove entries from /etc/hosts. 25 | 26 | ``` 27 | # Assign 127.0.0.1 to all of the given hostnames 28 | hostsfile add www.facebook.com www.twitter.com www.adroll.com 127.0.0.1 29 | # Remove all hostnames from /etc/hosts 30 | hostsfile remove www.facebook.com www.twitter.com www.adroll.com 31 | ``` 32 | 33 | You may need to run the above commands as root to write to `/etc/hosts` (which 34 | is modified atomically). 35 | 36 | To print the new file to stdout, instead of writing it: 37 | 38 | ``` 39 | hostsfile add --dry-run www.facebook.com www.twitter.com www.adroll.com 127.0.0.1 40 | ``` 41 | 42 | You can also pipe a hostsfile in: 43 | 44 | ``` 45 | cat /etc/hosts | hostsfile add --dry-run www.facebook.com www.twitter.com www.adroll.com 127.0.0.1 46 | ``` 47 | 48 | Or specify a file to read from at the command line: 49 | 50 | ``` 51 | hostsfile add --file=sample-hostsfile www.facebook.com www.twitter.com www.adroll.com 127.0.0.1 52 | ``` 53 | 54 | ## Library Usage 55 | 56 | You can also call the functions in this library from Go code. Here's an example 57 | where a hosts file is read, modified, and atomically written back to disk. 58 | 59 | ```go 60 | package main 61 | 62 | import ( 63 | "bytes" 64 | "fmt" 65 | "log" 66 | "net" 67 | "os" 68 | 69 | hostsfile "github.com/kevinburke/hostsfile/lib" 70 | ) 71 | 72 | func checkError(err error) { 73 | if err != nil { 74 | log.Fatal(err.Error()) 75 | } 76 | } 77 | 78 | func main() { 79 | f, err := os.Open("/etc/hosts") 80 | checkError(err) 81 | h, err := hostsfile.Decode(f) 82 | checkError(err) 83 | 84 | local, err := net.ResolveIPAddr("ip", "127.0.0.1") 85 | checkError(err) 86 | // Necessary for sites like facebook & gmail that resolve ipv6 addresses, 87 | // if your network supports ipv6 88 | ip6, err := net.ResolveIPAddr("ip", "::1") 89 | checkError(err) 90 | h.Set(*local, "www.facebook.com") 91 | h.Set(*ip6, "www.facebook.com") 92 | h.Set(*local, "news.ycombinator.com") 93 | h.Set(*ip6, "news.ycombinator.com") 94 | 95 | 96 | // Write to a temporary file and then atomically copy it into place. 97 | tmpf, err := os.CreateTemp("/tmp", "hostsfile-temp") 98 | checkError(err) 99 | 100 | err = hostsfile.Encode(tmpf, h) 101 | checkError(err) 102 | 103 | err = os.Chmod(tmp.Name(), 0644) 104 | checkError(err) 105 | 106 | err = os.Rename(tmp.Name(), "/etc/hosts") 107 | checkError(err) 108 | fmt.Println("done") 109 | } 110 | ``` 111 | 112 | [godoc]: https://godoc.org/github.com/kevinburke/hostsfile 113 | --------------------------------------------------------------------------------