// Package main implements an example CLI client package main import ( "context" "flag" "fmt" "os" "reflect" "strings" "codeberg.org/eduVPN/eduvpn-common/client" "codeberg.org/eduVPN/eduvpn-common/i18n" "codeberg.org/eduVPN/eduvpn-common/internal/commonver" "codeberg.org/eduVPN/eduvpn-common/types/cookie" srvtypes "codeberg.org/eduVPN/eduvpn-common/types/server" "github.com/pkg/browser" ) // Open a browser with xdg-open. func openBrowser(data any) { str, ok := data.(string) if !ok { return } go func() { err := browser.OpenURL(str) if err != nil { fmt.Fprintln(os.Stderr, "failed to open browser with error:", err) fmt.Println("Please open your browser manually") } }() } func getProfileInteractive(profiles *srvtypes.Profiles, data any) (string, error) { fmt.Printf("Multiple VPN profiles found. Please select a profile by entering e.g. 1") var ps strings.Builder var options []string i := 0 for k, v := range profiles.Map { ps.WriteString(fmt.Sprintf("\n%d - %s", i+1, i18n.GetLanguageMatched(v.DisplayName, "en"))) options = append(options, k) i++ } // Show the profiles fmt.Println(ps.String()) var idx int if _, err := fmt.Scanf("%d", &idx); err != nil || idx <= 0 || idx > len(profiles.Map) { fmt.Fprintln(os.Stderr, "invalid profile chosen, please retry") return getProfileInteractive(profiles, data) } p := options[idx-1] fmt.Println("Sending profile ID", p) return p, nil } func sendProfile(profile string, data any) { d, ok := data.(*srvtypes.RequiredAskTransition) if !ok { fmt.Fprintf(os.Stderr, "\ninvalid data type: %v\n", reflect.TypeOf(data)) os.Exit(1) } sps, ok := d.Data.(*srvtypes.Profiles) if !ok { fmt.Fprintf(os.Stderr, "\ninvalid data type for profiles: %v\n", reflect.TypeOf(d.Data)) os.Exit(1) } if profile == "" { gprof, err := getProfileInteractive(sps, data) if err != nil { fmt.Fprintf(os.Stderr, "failed getting profile interactively: %v\n", err) os.Exit(1) } profile = gprof } if err := d.C.Send(profile); err != nil { fmt.Fprintf(os.Stderr, "failed setting profile with error: %v\n", err) os.Exit(1) } } // The callback function // If OAuth is started we open the browser with the Auth URL // If we ask for a profile, we send the profile using command line input // Note that this has an additional argument, the vpn state which was wrapped into this callback function below. func stateCallback(_ client.FSMStateID, newState client.FSMStateID, data any, prof string, dir string) { if newState == client.StateOAuthStarted { openBrowser(data) } if newState == client.StateAskProfile { sendProfile(prof, data) } if newState == client.StateAskLocation { // removing is best effort _ = os.RemoveAll(dir) fmt.Fprint(os.Stderr, "An invalid secure location is stored. This CLI doesn't support interactively choosing a location yet. Give a correct location with the -country-code flag") os.Exit(1) } } // Get a config for Institute Access or Secure Internet Server. func getConfig(state *client.Client, url string, srvType srvtypes.Type, cc string, prof string) (*srvtypes.Configuration, error) { if !strings.HasPrefix(url, "http") { url = "https://" + url } ck := cookie.NewWithContext(context.Background()) defer ck.Cancel() //nolint:errcheck err := state.AddServer(ck, url, srvType, nil) if err != nil { // TODO: This is quite hacky :^) if !strings.Contains(err.Error(), "a secure internet server already exists.") { return nil, err } } if cc != "" { err = state.SetSecureLocation(url, cc) if err != nil { return nil, err } } if prof != "" { // this is best effort, e.g. if no server was chosen before this fails _ = state.SetProfileID(prof) //nolint:errcheck } return state.GetConfig(ck, url, srvType, false, false) } // Get a config for a single server, Institute Access or Secure Internet. func printConfig(url string, cc string, srvType srvtypes.Type, prof string) error { var c *client.Client var err error var dir string dir, err = os.MkdirTemp("", "eduvpn-common") if err != nil { return err } // removing is best effort defer os.RemoveAll(dir) //nolint:errcheck c, err = client.New( "org.eduvpn.app.linux", fmt.Sprintf("%s-cli", commonver.Version), dir, func(oldState client.FSMStateID, newState client.FSMStateID, data any) bool { stateCallback(oldState, newState, data, prof, dir) return true }, nil, ) if err != nil { return err } _ = c.Register() ck := cookie.NewWithContext(context.Background()) _, err = c.DiscoOrganizations(ck, false, "") if err != nil { return err } _, err = c.DiscoServers(ck, false, "") if err != nil { return err } defer c.Deregister() cfg, err := getConfig(c, url, srvType, cc, prof) if err != nil { return err } fmt.Println(cfg.VPNConfig) return nil } // The main function // It parses the arguments and executes the correct functions. func main() { cu := flag.String("get-custom", "", "The url of a custom server to connect to") u := flag.String("get-institute", "", "The url of an institute to connect to") sec := flag.String("get-secure", "", "Gets secure internet servers") cc := flag.String("country-code", "", "The country code to use in case of a secure internet server") prof := flag.String("profile", "", "The profile ID to choose") flag.Parse() // Connect to a VPN by getting an Institute Access config var err error switch { case *cu != "": err = printConfig(*cu, "", srvtypes.TypeCustom, *prof) case *u != "": err = printConfig(*u, "", srvtypes.TypeInstituteAccess, *prof) case *sec != "": err = printConfig(*sec, *cc, srvtypes.TypeSecureInternet, *prof) default: flag.PrintDefaults() } if err != nil { fmt.Fprintf(os.Stderr, "failed to get a VPN config: %v\n", err) } }